diff --git a/em2rp/lib/views/calendar_page.dart b/em2rp/lib/views/calendar_page.dart index 0f7ccc5..2e5b5a4 100644 --- a/em2rp/lib/views/calendar_page.dart +++ b/em2rp/lib/views/calendar_page.dart @@ -10,7 +10,7 @@ import 'package:em2rp/views/widgets/calendar_widgets/event_details.dart'; import 'package:intl/date_symbol_data_local.dart'; import 'package:em2rp/views/widgets/calendar_widgets/month_view.dart'; import 'package:em2rp/views/widgets/calendar_widgets/week_view.dart'; -import 'package:em2rp/views/pages/event_add_page.dart'; +import 'package:em2rp/views/event_add_page.dart'; import 'package:em2rp/views/widgets/calendar_widgets/mobile_calendar_view.dart'; import 'package:em2rp/utils/colors.dart'; @@ -40,23 +40,43 @@ class _CalendarPageState extends State { final events = eventProvider.events; if (events.isNotEmpty) { final now = DateTime.now(); - events.sort((a, b) => a.startDateTime.compareTo(b.startDateTime)); - int closestIdx = 0; - Duration minDiff = (events[0].startDateTime.difference(now)).abs(); - for (int i = 1; i < events.length; i++) { - final diff = (events[i].startDateTime.difference(now)).abs(); - if (diff < minDiff) { - minDiff = diff; - closestIdx = i; + // Pour mobile : sélectionner le premier événement du jour ou le prochain événement à venir + final todayEvents = events + .where((e) => + e.startDateTime.year == now.year && + e.startDateTime.month == now.month && + e.startDateTime.day == now.day) + .toList() + ..sort((a, b) => a.startDateTime.compareTo(b.startDateTime)); + EventModel? selected; + DateTime? selectedDay; + int selectedEventIndex = 0; + if (todayEvents.isNotEmpty) { + selected = todayEvents[0]; + selectedDay = DateTime(now.year, now.month, now.day); + } else { + // Chercher le prochain événement à venir + final futureEvents = events + .where((e) => e.startDateTime.isAfter(now)) + .toList() + ..sort((a, b) => a.startDateTime.compareTo(b.startDateTime)); + if (futureEvents.isNotEmpty) { + selected = futureEvents[0]; + selectedDay = DateTime(selected.startDateTime.year, + selected.startDateTime.month, selected.startDateTime.day); + } else { + // Aucun événement à venir, prendre le plus proche dans le passé + events.sort((a, b) => a.startDateTime.compareTo(b.startDateTime)); + selected = events.last; + selectedDay = DateTime(selected.startDateTime.year, + selected.startDateTime.month, selected.startDateTime.day); } } - final closestEvent = events[closestIdx]; setState(() { - _selectedDay = DateTime(closestEvent.startDateTime.year, - closestEvent.startDateTime.month, closestEvent.startDateTime.day); - _focusedDay = _selectedDay!; + _selectedDay = selectedDay; + _focusedDay = selectedDay!; _selectedEventIndex = 0; - _selectedEvent = closestEvent; + _selectedEvent = selected; }); } }); @@ -188,111 +208,221 @@ class _CalendarPageState extends State { ? eventsForSelectedDay[_selectedEventIndex] : null; - return Stack( - children: [ - // Calendrier + détails en dessous - AnimatedPositioned( - duration: const Duration(milliseconds: 400), - curve: Curves.easeInOut, - top: _calendarCollapsed ? -600 : 0, // cache le calendrier en haut - left: 0, - right: 0, - height: _calendarCollapsed ? 0 : null, - child: Container( - height: MediaQuery.of(context).size.height, - child: Column( - children: [ - _buildMonthHeader(context), - if (!_calendarCollapsed) - MobileCalendarView( - focusedDay: _focusedDay, - selectedDay: _selectedDay, - events: eventProvider.events, - onDaySelected: (day) { - final eventsForDay = eventProvider.events - .where((e) => - e.startDateTime.year == day.year && - e.startDateTime.month == day.month && - e.startDateTime.day == day.day) - .toList() - ..sort((a, b) => - a.startDateTime.compareTo(b.startDateTime)); - setState(() { - _selectedDay = day; - _calendarCollapsed = false; - _selectedEventIndex = 0; - _selectedEvent = - eventsForDay.isNotEmpty ? eventsForDay[0] : null; - }); - }, - ), - Expanded( - child: hasEvents - ? EventDetails( - event: eventsForSelectedDay[_selectedEventIndex], - selectedDate: _selectedDay, - events: eventsForSelectedDay.cast(), - onSelectEvent: (event, date) { - final idx = eventsForSelectedDay - .indexWhere((e) => e.id == event.id); - setState(() { - _selectedEventIndex = idx >= 0 ? idx : 0; - _selectedEvent = event; - }); - }, - ) - : Center( - child: - Text('Aucun événement ne démarre à cette date')), - ), - ], - ), - ), - ), - // Vue détail (prend tout l'espace quand calendrier caché) - if (_calendarCollapsed && _selectedDay != null) + // GESTURE DETECTOR pour swipe vertical (plier/déplier) et horizontal (mois) + return GestureDetector( + onVerticalDragEnd: (details) { + if (details.primaryVelocity != null) { + if (details.primaryVelocity! < -200) { + // Swipe vers le haut : plier + setState(() { + _calendarCollapsed = true; + }); + } else if (details.primaryVelocity! > 200) { + // Swipe vers le bas : déplier + setState(() { + _calendarCollapsed = false; + }); + } + } + }, + onHorizontalDragEnd: (details) { + if (details.primaryVelocity != null) { + if (details.primaryVelocity! < -200) { + // Swipe gauche : mois suivant + setState(() { + _focusedDay = + DateTime(_focusedDay.year, _focusedDay.month + 1, 1); + }); + } else if (details.primaryVelocity! > 200) { + // Swipe droite : mois précédent + setState(() { + _focusedDay = + DateTime(_focusedDay.year, _focusedDay.month - 1, 1); + }); + } + } + }, + child: Stack( + children: [ + // Calendrier + détails en dessous AnimatedPositioned( duration: const Duration(milliseconds: 400), curve: Curves.easeInOut, - top: _calendarCollapsed ? 0 : 600, + top: _calendarCollapsed ? -600 : 0, // cache le calendrier en haut left: 0, right: 0, - bottom: 0, + height: _calendarCollapsed ? 0 : null, child: Container( height: MediaQuery.of(context).size.height, child: Column( children: [ _buildMonthHeader(context), - Expanded( - child: Stack( - children: [ - if (currentEvent != null) - EventDetails( - event: currentEvent, - selectedDate: _selectedDay, - events: eventsForSelectedDay.cast(), - onSelectEvent: (event, date) { - final idx = eventsForSelectedDay - .indexWhere((e) => e.id == event.id); - setState(() { - _selectedEventIndex = idx >= 0 ? idx : 0; - _selectedEvent = event; - }); - }, - ), - if (!hasEvents) - Center( - child: - Text('Aucun événement ne démarre à cette date'), - ), - ], + if (!_calendarCollapsed) + // Ajout d'un GestureDetector pour swipe horizontal sur le calendrier + GestureDetector( + onHorizontalDragEnd: (details) { + if (details.primaryVelocity != null) { + if (details.primaryVelocity! < -200) { + // Swipe gauche : mois suivant + setState(() { + _focusedDay = DateTime( + _focusedDay.year, _focusedDay.month + 1, 1); + }); + } else if (details.primaryVelocity! > 200) { + // Swipe droite : mois précédent + setState(() { + _focusedDay = DateTime( + _focusedDay.year, _focusedDay.month - 1, 1); + }); + } + } + }, + child: MobileCalendarView( + focusedDay: _focusedDay, + selectedDay: _selectedDay, + events: eventProvider.events, + onDaySelected: (day) { + final eventsForDay = eventProvider.events + .where((e) => + e.startDateTime.year == day.year && + e.startDateTime.month == day.month && + e.startDateTime.day == day.day) + .toList() + ..sort((a, b) => + a.startDateTime.compareTo(b.startDateTime)); + setState(() { + _selectedDay = day; + _calendarCollapsed = false; + _selectedEventIndex = 0; + _selectedEvent = eventsForDay.isNotEmpty + ? eventsForDay[0] + : null; + }); + }, + ), ), + Expanded( + child: hasEvents + // Ajout d'un GestureDetector pour swipe horizontal sur le détail événement + ? GestureDetector( + onHorizontalDragEnd: (details) { + if (details.primaryVelocity != null) { + if (details.primaryVelocity! < -200) { + // Swipe gauche : événement suivant + if (_selectedEventIndex < + eventsForSelectedDay.length - 1) { + setState(() { + _selectedEventIndex++; + _selectedEvent = eventsForSelectedDay[ + _selectedEventIndex]; + }); + } + } else if (details.primaryVelocity! > 200) { + // Swipe droite : événement précédent + if (_selectedEventIndex > 0) { + setState(() { + _selectedEventIndex--; + _selectedEvent = eventsForSelectedDay[ + _selectedEventIndex]; + }); + } + } + } + }, + child: EventDetails( + event: eventsForSelectedDay[_selectedEventIndex], + selectedDate: _selectedDay, + events: eventsForSelectedDay.cast(), + onSelectEvent: (event, date) { + final idx = eventsForSelectedDay + .indexWhere((e) => e.id == event.id); + setState(() { + _selectedEventIndex = idx >= 0 ? idx : 0; + _selectedEvent = event; + }); + }, + ), + ) + : Center( + child: Text( + 'Aucun événement ne démarre à cette date')), ), ], ), ), ), - ], + // Vue détail (prend tout l'espace quand calendrier caché) + if (_calendarCollapsed && _selectedDay != null) + AnimatedPositioned( + duration: const Duration(milliseconds: 400), + curve: Curves.easeInOut, + top: _calendarCollapsed ? 0 : 600, + left: 0, + right: 0, + bottom: 0, + child: Container( + height: MediaQuery.of(context).size.height, + child: Column( + children: [ + _buildMonthHeader(context), + Expanded( + child: Stack( + children: [ + if (currentEvent != null) + // Ajout d'un GestureDetector pour swipe horizontal sur le détail événement + GestureDetector( + onHorizontalDragEnd: (details) { + if (details.primaryVelocity != null) { + if (details.primaryVelocity! < -200) { + // Swipe gauche : événement suivant + if (_selectedEventIndex < + eventsForSelectedDay.length - 1) { + setState(() { + _selectedEventIndex++; + _selectedEvent = eventsForSelectedDay[ + _selectedEventIndex]; + }); + } + } else if (details.primaryVelocity! > 200) { + // Swipe droite : événement précédent + if (_selectedEventIndex > 0) { + setState(() { + _selectedEventIndex--; + _selectedEvent = eventsForSelectedDay[ + _selectedEventIndex]; + }); + } + } + } + }, + child: EventDetails( + event: currentEvent, + selectedDate: _selectedDay, + events: eventsForSelectedDay.cast(), + onSelectEvent: (event, date) { + final idx = eventsForSelectedDay + .indexWhere((e) => e.id == event.id); + setState(() { + _selectedEventIndex = idx >= 0 ? idx : 0; + _selectedEvent = event; + }); + }, + ), + ), + if (!hasEvents) + Center( + child: Text( + 'Aucun événement ne démarre à cette date'), + ), + ], + ), + ), + ], + ), + ), + ), + ], + ), ); } diff --git a/em2rp/lib/views/event_add_page.dart b/em2rp/lib/views/event_add_page.dart new file mode 100644 index 0000000..64e16aa --- /dev/null +++ b/em2rp/lib/views/event_add_page.dart @@ -0,0 +1,788 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:em2rp/providers/event_provider.dart'; +import 'package:em2rp/models/event_model.dart'; +import 'package:intl/intl.dart'; +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:em2rp/views/widgets/inputs/int_stepper_field.dart'; +import 'package:em2rp/models/user_model.dart'; +import 'package:em2rp/views/widgets/image/profile_picture.dart'; +import 'package:flutter/services.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:firebase_storage/firebase_storage.dart'; +import 'package:path/path.dart' as p; +import 'dart:convert'; +import 'package:http/http.dart' as http; +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:em2rp/providers/local_user_provider.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_dropzone/flutter_dropzone.dart'; +import 'package:em2rp/views/widgets/inputs/dropzone_upload_widget.dart'; +import 'package:em2rp/views/widgets/user_management/user_multi_select_widget.dart'; +import 'package:em2rp/views/widgets/inputs/option_selector_widget.dart'; +// ignore: avoid_web_libraries_in_flutter +import 'dart:html' as html; + +class EventAddPage extends StatefulWidget { + const EventAddPage({super.key}); + + @override + State createState() => _EventAddPageState(); +} + +class _EventAddPageState extends State { + final _formKey = GlobalKey(); + final TextEditingController _nameController = TextEditingController(); + final TextEditingController _descriptionController = TextEditingController(); + final TextEditingController _basePriceController = TextEditingController(); + final TextEditingController _installationController = TextEditingController(); + final TextEditingController _disassemblyController = TextEditingController(); + final TextEditingController _addressController = TextEditingController(); + DateTime? _startDateTime; + DateTime? _endDateTime; + bool _isLoading = false; + String? _error; + String? _success; + String? _selectedEventType; + final List _eventTypes = ['Bal', 'Mariage', 'Anniversaire']; + final Map _eventTypeDefaultPrices = { + 'Bal': 800.0, + 'Mariage': 1500.0, + 'Anniversaire': 500.0, + }; + int _descriptionMaxLines = 3; + List _selectedUserIds = []; + List _allUsers = []; + bool _isLoadingUsers = true; + List> _uploadedFiles = []; + DropzoneViewController? _dropzoneController; + bool _isDropzoneHighlighted = false; + List> _selectedOptions = []; + bool _formChanged = false; + EventStatus _selectedStatus = EventStatus.waitingForApproval; + + @override + void initState() { + super.initState(); + _descriptionController.addListener(_handleDescriptionChange); + _fetchUsers(); + _nameController.addListener(_onAnyFieldChanged); + _basePriceController.addListener(_onAnyFieldChanged); + _installationController.addListener(_onAnyFieldChanged); + _disassemblyController.addListener(_onAnyFieldChanged); + _addressController.addListener(_onAnyFieldChanged); + _descriptionController.addListener(_onAnyFieldChanged); + _addBeforeUnloadListener(); + _selectedStatus = EventStatus.waitingForApproval; + } + + void _handleDescriptionChange() { + final lines = '\n'.allMatches(_descriptionController.text).length + 1; + setState(() { + _descriptionMaxLines = lines.clamp(3, 6); + }); + } + + void _onAnyFieldChanged() { + if (!_formChanged) { + setState(() { + _formChanged = true; + }); + } + } + + Future _fetchUsers() async { + final snapshot = await FirebaseFirestore.instance.collection('users').get(); + setState(() { + _allUsers = snapshot.docs + .map((doc) => UserModel.fromMap(doc.data(), doc.id)) + .toList(); + _isLoadingUsers = false; + }); + } + + void _onEventTypeChanged(String? newType) { + if (newType == _selectedEventType) return; + setState(() { + _selectedEventType = newType; + if (newType != null) { + // Appliquer le prix par défaut si champ vide ou si type changé + final defaultPrice = _eventTypeDefaultPrices[newType] ?? 0.0; + if (_basePriceController.text.isEmpty || + (_selectedEventType != null && + _basePriceController.text == + (_eventTypeDefaultPrices[_selectedEventType] ?? '') + .toString())) { + _basePriceController.text = defaultPrice.toStringAsFixed(2); + } + // Efface les options non compatibles + final before = _selectedOptions.length; + _selectedOptions.removeWhere((opt) { + final types = opt['compatibleTypes'] as List?; + if (types == null) return true; + return !types.contains(newType); + }); + if (_selectedOptions.length < before && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Certaines options ont été retirées car elles ne sont pas compatibles avec le type "$newType".')), + ); + } + } else { + _selectedOptions.clear(); + } + _onAnyFieldChanged(); + }); + } + + @override + void dispose() { + _nameController.dispose(); + _descriptionController.dispose(); + _basePriceController.dispose(); + _installationController.dispose(); + _disassemblyController.dispose(); + _addressController.dispose(); + _removeBeforeUnloadListener(); + super.dispose(); + } + + // --- Web: beforeunload pour empêcher la fermeture sans confirmation --- + void _addBeforeUnloadListener() { + if (kIsWeb) { + html.window.onBeforeUnload.listen(_beforeUnloadHandler); + } + } + + void _removeBeforeUnloadListener() { + if (kIsWeb) { + // Il n'est pas possible de retirer un listener anonyme, donc on ne fait rien ici. + // Pour une gestion plus fine, il faudrait stocker la référence du listener. + } + } + + void _beforeUnloadHandler(html.Event event) { + if (_formChanged) { + event.preventDefault(); + // Pour Chrome/Edge/Firefox, il faut définir returnValue + // ignore: unsafe_html + (event as dynamic).returnValue = ''; + } + } + + Future _onWillPop() async { + if (!_formChanged) return true; + final shouldLeave = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Quitter la page ?'), + content: const Text( + 'Les modifications non enregistrées seront perdues. Voulez-vous vraiment quitter ?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Annuler'), + ), + ElevatedButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Quitter'), + ), + ], + ), + ); + return shouldLeave ?? false; + } + + Future _pickAndUploadFiles() async { + final result = await FilePicker.platform + .pickFiles(allowMultiple: true, withData: true); + if (result != null && result.files.isNotEmpty) { + setState(() => _isLoading = true); + try { + List> files = []; + for (final file in result.files) { + final fileBytes = file.bytes; + final fileName = file.name; + if (fileBytes != null) { + final ref = FirebaseStorage.instance.ref().child( + 'events/temp/${DateTime.now().millisecondsSinceEpoch}_$fileName'); + final uploadTask = await ref.putData(fileBytes); + final url = await uploadTask.ref.getDownloadURL(); + files.add({'name': fileName, 'url': url}); + } else { + setState(() { + _error = "Impossible de lire le fichier ${file.name}"; + }); + } + } + setState(() { + _uploadedFiles.addAll(files); + }); + } catch (e) { + setState(() { + _error = 'Erreur lors de l\'upload : $e'; + }); + } finally { + setState(() => _isLoading = false); + } + } + } + + Future moveEventFileHttp({ + required String sourcePath, + required String destinationPath, + }) async { + final url = Uri.parse( + 'https://us-central1-em2rp-951dc.cloudfunctions.net/moveEventFileV2'); + final user = FirebaseAuth.instance.currentUser; + final idToken = await user?.getIdToken(); + final response = await http.post( + url, + headers: { + 'Content-Type': 'application/json', + if (idToken != null) 'Authorization': 'Bearer $idToken', + }, + body: jsonEncode({ + 'data': { + 'sourcePath': sourcePath, + 'destinationPath': destinationPath, + } + }), + ); + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + if (data['url'] != null) { + return data['url'] as String; + } else if (data['result'] != null && data['result']['url'] != null) { + return data['result']['url'] as String; + } + return null; + } else { + print('Erreur Cloud Function: \\n${response.body}'); + return null; + } + } + + Future _submit() async { + if (!_formKey.currentState!.validate() || + _startDateTime == null || + _endDateTime == null || + _selectedEventType == null || + _addressController.text.isEmpty) return; + if (_endDateTime!.isBefore(_startDateTime!) || + _endDateTime!.isAtSameMomentAs(_startDateTime!)) { + setState(() { + _error = "La date de fin doit être postérieure à la date de début."; + }); + return; + } + setState(() { + _isLoading = true; + _error = null; + _success = null; + }); + try { + final eventProvider = Provider.of(context, listen: false); + final newEvent = EventModel( + id: '', + name: _nameController.text.trim(), + description: _descriptionController.text.trim(), + startDateTime: _startDateTime!, + endDateTime: _endDateTime!, + basePrice: double.tryParse(_basePriceController.text) ?? 0.0, + installationTime: int.tryParse(_installationController.text) ?? 0, + disassemblyTime: int.tryParse(_disassemblyController.text) ?? 0, + eventTypeId: _selectedEventType!, + customerId: '', + address: _addressController.text.trim(), + workforce: _selectedUserIds + .map((id) => FirebaseFirestore.instance.collection('users').doc(id)) + .toList(), + latitude: 0.0, + longitude: 0.0, + documents: _uploadedFiles, + options: _selectedOptions + .map((opt) => { + 'name': opt['name'], + 'price': opt['price'], + }) + .toList(), + status: _selectedStatus, + ); + final docRef = await FirebaseFirestore.instance + .collection('events') + .add(newEvent.toMap()); + final eventId = docRef.id; + List> newFiles = []; + for (final file in _uploadedFiles) { + final fileName = file['name']!; + final oldUrl = file['url']!; + String sourcePath; + final tempPattern = RegExp(r'events/temp/[^?]+'); + final match = tempPattern.firstMatch(oldUrl); + if (match != null) { + sourcePath = match.group(0)!; + } else { + final tempFileName = + Uri.decodeComponent(oldUrl.split('/').last.split('?').first); + sourcePath = tempFileName; + } + final destinationPath = 'events/$eventId/$fileName'; + final newUrl = await moveEventFileHttp( + sourcePath: sourcePath, + destinationPath: destinationPath, + ); + if (newUrl != null) { + newFiles.add({'name': fileName, 'url': newUrl}); + } else { + newFiles.add({'name': fileName, 'url': oldUrl}); + } + } + await docRef.update({'documents': newFiles}); + final localUserProvider = + Provider.of(context, listen: false); + final userId = localUserProvider.uid; + final canViewAllEvents = + localUserProvider.hasPermission('view_all_events'); + if (userId != null) { + await eventProvider.loadUserEvents(userId, + canViewAllEvents: canViewAllEvents); + } + setState(() { + _success = "Événement créé avec succès !"; + }); + if (context.mounted) Navigator.of(context).pop(); + } catch (e) { + setState(() { + _error = "Erreur lors de la création : $e"; + }); + } finally { + setState(() { + _isLoading = false; + }); + } + } + + Widget _buildSectionTitle(String title) { + return Padding( + padding: const EdgeInsets.only(top: 16.0, bottom: 8.0), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + title, + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isMobile = MediaQuery.of(context).size.width < 600; + return WillPopScope( + onWillPop: _onWillPop, + child: Scaffold( + appBar: AppBar( + title: const Text('Créer un événement'), + ), + body: Center( + child: SingleChildScrollView( + child: (isMobile + ? Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 12), + child: Container( + // Pas de Card sur mobile, juste un conteneur + child: _buildFormContent(isMobile), + ), + ) + : Card( + elevation: 6, + margin: const EdgeInsets.all(24), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(18)), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 32, vertical: 32), + child: _buildFormContent(isMobile), + ), + )), + ), + ), + ), + ); + } + + Widget _buildFormContent(bool isMobile) { + return Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.only(top: 0.0, bottom: 4.0), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + 'Informations principales', + style: + const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + ), + ), + TextFormField( + controller: _nameController, + decoration: const InputDecoration( + labelText: 'Nom de l\'événement', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.event), + ), + validator: (v) => v == null || v.isEmpty ? 'Champ requis' : null, + ), + const SizedBox(height: 16), + DropdownButtonFormField( + value: _selectedEventType, + items: _eventTypes + .map((type) => DropdownMenuItem( + value: type, + child: Text(type), + )) + .toList(), + onChanged: _onEventTypeChanged, + decoration: const InputDecoration( + labelText: 'Type d\'événement', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.category), + ), + validator: (v) => v == null ? 'Sélectionnez un type' : null, + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: GestureDetector( + onTap: () async { + final picked = await showDatePicker( + context: context, + initialDate: DateTime.now(), + firstDate: DateTime(2020), + lastDate: DateTime(2099), + ); + if (picked != null) { + final time = await showTimePicker( + context: context, + initialTime: TimeOfDay.now(), + ); + if (time != null) { + setState(() { + _startDateTime = DateTime( + picked.year, + picked.month, + picked.day, + time.hour, + time.minute, + ); + if (_endDateTime != null && + (_endDateTime!.isBefore(_startDateTime!) || + _endDateTime! + .isAtSameMomentAs(_startDateTime!))) { + _endDateTime = null; + } + }); + } + } + }, + child: AbsorbPointer( + child: TextFormField( + readOnly: true, + decoration: InputDecoration( + labelText: 'Début', + border: const OutlineInputBorder(), + prefixIcon: const Icon(Icons.calendar_today), + suffixIcon: const Icon(Icons.edit_calendar), + ), + controller: TextEditingController( + text: _startDateTime == null + ? '' + : DateFormat('dd/MM/yyyy HH:mm') + .format(_startDateTime!), + ), + validator: (v) => + _startDateTime == null ? 'Champ requis' : null, + ), + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: GestureDetector( + onTap: _startDateTime == null + ? null + : () async { + final picked = await showDatePicker( + context: context, + initialDate: + _startDateTime!.add(const Duration(hours: 1)), + firstDate: _startDateTime!, + lastDate: DateTime(2099), + ); + if (picked != null) { + final time = await showTimePicker( + context: context, + initialTime: TimeOfDay.now(), + ); + if (time != null) { + setState(() { + _endDateTime = DateTime( + picked.year, + picked.month, + picked.day, + time.hour, + time.minute, + ); + }); + } + } + }, + child: AbsorbPointer( + child: TextFormField( + readOnly: true, + decoration: InputDecoration( + labelText: 'Fin', + border: const OutlineInputBorder(), + prefixIcon: const Icon(Icons.calendar_today), + suffixIcon: const Icon(Icons.edit_calendar), + ), + controller: TextEditingController( + text: _endDateTime == null + ? '' + : DateFormat('dd/MM/yyyy HH:mm') + .format(_endDateTime!), + ), + validator: (v) => _endDateTime == null + ? 'Champ requis' + : (_startDateTime != null && + _endDateTime != null && + (_endDateTime!.isBefore(_startDateTime!) || + _endDateTime! + .isAtSameMomentAs(_startDateTime!))) + ? 'La date de fin doit être après la date de début' + : null, + ), + ), + ), + ), + ], + ), + const SizedBox(height: 16), + TextFormField( + controller: _basePriceController, + decoration: const InputDecoration( + labelText: 'Prix de base (€)', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.euro), + hintText: '1050.50', + ), + keyboardType: const TextInputType.numberWithOptions(decimal: true), + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,2}')), + ], + validator: (value) { + if (value == null || value.isEmpty) { + return 'Le prix de base est requis'; + } + final price = double.tryParse(value.replaceAll(',', '.')); + if (price == null) { + return 'Veuillez entrer un nombre valide'; + } + return null; + }, + onChanged: (_) => _onAnyFieldChanged(), + ), + const SizedBox(height: 16), + OptionSelectorWidget( + eventType: _selectedEventType, + selectedOptions: _selectedOptions, + onChanged: (opts) => setState(() => _selectedOptions = opts), + onRemove: (name) { + setState(() { + _selectedOptions.removeWhere((o) => o['name'] == name); + }); + }, + eventTypeRequired: _selectedEventType == null, + isMobile: isMobile, + ), + _buildSectionTitle('Détails'), + AnimatedContainer( + duration: const Duration(milliseconds: 200), + constraints: BoxConstraints( + minHeight: 48, + maxHeight: isMobile ? 48.0 * 20 : 48.0 * 10, + ), + child: TextFormField( + controller: _descriptionController, + minLines: 1, + maxLines: _descriptionMaxLines > (isMobile ? 20 : 10) + ? (isMobile ? 20 : 10) + : _descriptionMaxLines, + decoration: const InputDecoration( + labelText: 'Description', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.description), + ), + ), + ), + const SizedBox(height: 20), + Row( + children: [ + Expanded( + child: IntStepperField( + label: 'Installation (h)', + controller: _installationController, + min: 0, + max: 99, + ), + ), + const SizedBox(width: 16), + Expanded( + child: IntStepperField( + label: 'Démontage (h)', + controller: _disassemblyController, + min: 0, + max: 99, + ), + ), + ], + ), + _buildSectionTitle('Adresse'), + TextFormField( + controller: _addressController, + decoration: const InputDecoration( + labelText: 'Adresse', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.location_on), + ), + validator: (v) => v == null || v.isEmpty ? 'Champ requis' : null, + ), + _buildSectionTitle('Personnel'), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: UserMultiSelectWidget( + allUsers: _allUsers, + selectedUserIds: _selectedUserIds, + onChanged: (ids) => setState(() => _selectedUserIds = ids), + isLoading: _isLoadingUsers, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionTitle('Documents'), + if (isMobile) + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ElevatedButton.icon( + icon: const Icon(Icons.attach_file), + label: const Text('Ajouter un fichier'), + onPressed: _isLoading ? null : _pickAndUploadFiles, + ), + const SizedBox(height: 8), + ..._uploadedFiles.map((file) => ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.insert_drive_file), + title: Text(file['name'] ?? ''), + trailing: IconButton( + icon: const Icon(Icons.delete), + onPressed: _isLoading + ? null + : () { + setState(() { + _uploadedFiles.remove(file); + }); + }, + ), + )), + if (_error != null) + Padding( + padding: const EdgeInsets.only(top: 4.0), + child: Text(_error!, + style: const TextStyle(color: Colors.red)), + ), + if (_success != null) + Padding( + padding: const EdgeInsets.only(top: 4.0), + child: Text(_success!, + style: const TextStyle(color: Colors.green)), + ), + ], + ), + if (!isMobile) + DropzoneUploadWidget( + uploadedFiles: _uploadedFiles, + onFilesChanged: (files) => + setState(() => _uploadedFiles = files), + isLoading: _isLoading, + error: _error, + success: _success, + ), + ], + ), + ), + ], + ), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: _isLoading + ? null + : () async { + final shouldLeave = await _onWillPop(); + if (shouldLeave && context.mounted) { + Navigator.of(context).pop(); + } + }, + child: const Text('Annuler'), + ), + const SizedBox(width: 8), + ElevatedButton.icon( + icon: const Icon(Icons.check), + onPressed: _isLoading ? null : _submit, + label: _isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Créer'), + ), + ], + ), + const SizedBox(height: 16), + Center( + child: ElevatedButton.icon( + icon: const Icon(Icons.check_circle, color: Colors.white), + label: const Text('Définir cet événement comme confirmé'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green, + foregroundColor: Colors.white, + textStyle: const TextStyle(fontWeight: FontWeight.bold), + ), + onPressed: null, + ), + ), + ], + ), + ); + } +} diff --git a/em2rp/lib/views/pages/event_add_page.dart b/em2rp/lib/views/pages/event_add_page.dart deleted file mode 100644 index 0d191c2..0000000 --- a/em2rp/lib/views/pages/event_add_page.dart +++ /dev/null @@ -1,751 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import 'package:em2rp/providers/event_provider.dart'; -import 'package:em2rp/models/event_model.dart'; -import 'package:intl/intl.dart'; -import 'package:cloud_firestore/cloud_firestore.dart'; -import 'package:em2rp/views/widgets/inputs/int_stepper_field.dart'; -import 'package:em2rp/models/user_model.dart'; -import 'package:em2rp/views/widgets/image/profile_picture.dart'; -import 'package:flutter/services.dart'; -import 'package:file_picker/file_picker.dart'; -import 'package:firebase_storage/firebase_storage.dart'; -import 'package:path/path.dart' as p; -import 'dart:convert'; -import 'package:http/http.dart' as http; -import 'package:firebase_auth/firebase_auth.dart'; -import 'package:em2rp/providers/local_user_provider.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter_dropzone/flutter_dropzone.dart'; -import 'package:em2rp/views/widgets/inputs/dropzone_upload_widget.dart'; -import 'package:em2rp/views/widgets/user_management/user_multi_select_widget.dart'; -import 'package:em2rp/views/widgets/inputs/option_selector_widget.dart'; -// ignore: avoid_web_libraries_in_flutter -import 'dart:html' as html; - -class EventAddPage extends StatefulWidget { - const EventAddPage({super.key}); - - @override - State createState() => _EventAddPageState(); -} - -class _EventAddPageState extends State { - final _formKey = GlobalKey(); - final TextEditingController _nameController = TextEditingController(); - final TextEditingController _descriptionController = TextEditingController(); - final TextEditingController _basePriceController = TextEditingController(); - final TextEditingController _installationController = TextEditingController(); - final TextEditingController _disassemblyController = TextEditingController(); - final TextEditingController _addressController = TextEditingController(); - DateTime? _startDateTime; - DateTime? _endDateTime; - bool _isLoading = false; - String? _error; - String? _success; - String? _selectedEventType; - final List _eventTypes = ['Bal', 'Mariage', 'Anniversaire']; - final Map _eventTypeDefaultPrices = { - 'Bal': 800.0, - 'Mariage': 1500.0, - 'Anniversaire': 500.0, - }; - int _descriptionMaxLines = 3; - List _selectedUserIds = []; - List _allUsers = []; - bool _isLoadingUsers = true; - List> _uploadedFiles = []; - DropzoneViewController? _dropzoneController; - bool _isDropzoneHighlighted = false; - List> _selectedOptions = []; - bool _formChanged = false; - EventStatus _selectedStatus = EventStatus.waitingForApproval; - - @override - void initState() { - super.initState(); - _descriptionController.addListener(_handleDescriptionChange); - _fetchUsers(); - _nameController.addListener(_onAnyFieldChanged); - _basePriceController.addListener(_onAnyFieldChanged); - _installationController.addListener(_onAnyFieldChanged); - _disassemblyController.addListener(_onAnyFieldChanged); - _addressController.addListener(_onAnyFieldChanged); - _descriptionController.addListener(_onAnyFieldChanged); - _addBeforeUnloadListener(); - _selectedStatus = EventStatus.waitingForApproval; - } - - void _handleDescriptionChange() { - final lines = '\n'.allMatches(_descriptionController.text).length + 1; - setState(() { - _descriptionMaxLines = lines.clamp(3, 6); - }); - } - - void _onAnyFieldChanged() { - if (!_formChanged) { - setState(() { - _formChanged = true; - }); - } - } - - Future _fetchUsers() async { - final snapshot = await FirebaseFirestore.instance.collection('users').get(); - setState(() { - _allUsers = snapshot.docs - .map((doc) => UserModel.fromMap(doc.data(), doc.id)) - .toList(); - _isLoadingUsers = false; - }); - } - - void _onEventTypeChanged(String? newType) { - if (newType == _selectedEventType) return; - setState(() { - _selectedEventType = newType; - if (newType != null) { - // Appliquer le prix par défaut si champ vide ou si type changé - final defaultPrice = _eventTypeDefaultPrices[newType] ?? 0.0; - if (_basePriceController.text.isEmpty || - (_selectedEventType != null && - _basePriceController.text == - (_eventTypeDefaultPrices[_selectedEventType] ?? '') - .toString())) { - _basePriceController.text = defaultPrice.toStringAsFixed(2); - } - // Efface les options non compatibles - final before = _selectedOptions.length; - _selectedOptions.removeWhere((opt) { - final types = opt['compatibleTypes'] as List?; - if (types == null) return true; - return !types.contains(newType); - }); - if (_selectedOptions.length < before && context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - 'Certaines options ont été retirées car elles ne sont pas compatibles avec le type "$newType".')), - ); - } - } else { - _selectedOptions.clear(); - } - _onAnyFieldChanged(); - }); - } - - @override - void dispose() { - _nameController.dispose(); - _descriptionController.dispose(); - _basePriceController.dispose(); - _installationController.dispose(); - _disassemblyController.dispose(); - _addressController.dispose(); - _removeBeforeUnloadListener(); - super.dispose(); - } - - // --- Web: beforeunload pour empêcher la fermeture sans confirmation --- - void _addBeforeUnloadListener() { - if (kIsWeb) { - html.window.onBeforeUnload.listen(_beforeUnloadHandler); - } - } - - void _removeBeforeUnloadListener() { - if (kIsWeb) { - // Il n'est pas possible de retirer un listener anonyme, donc on ne fait rien ici. - // Pour une gestion plus fine, il faudrait stocker la référence du listener. - } - } - - void _beforeUnloadHandler(html.Event event) { - if (_formChanged) { - event.preventDefault(); - // Pour Chrome/Edge/Firefox, il faut définir returnValue - // ignore: unsafe_html - (event as dynamic).returnValue = ''; - } - } - - Future _onWillPop() async { - if (!_formChanged) return true; - final shouldLeave = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Quitter la page ?'), - content: const Text( - 'Les modifications non enregistrées seront perdues. Voulez-vous vraiment quitter ?'), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: const Text('Annuler'), - ), - ElevatedButton( - onPressed: () => Navigator.of(context).pop(true), - child: const Text('Quitter'), - ), - ], - ), - ); - return shouldLeave ?? false; - } - - Future _pickAndUploadFiles() async { - final result = await FilePicker.platform - .pickFiles(allowMultiple: true, withData: true); - if (result != null && result.files.isNotEmpty) { - setState(() => _isLoading = true); - try { - List> files = []; - for (final file in result.files) { - final fileBytes = file.bytes; - final fileName = file.name; - if (fileBytes != null) { - final ref = FirebaseStorage.instance.ref().child( - 'events/temp/${DateTime.now().millisecondsSinceEpoch}_$fileName'); - final uploadTask = await ref.putData(fileBytes); - final url = await uploadTask.ref.getDownloadURL(); - files.add({'name': fileName, 'url': url}); - } else { - setState(() { - _error = "Impossible de lire le fichier ${file.name}"; - }); - } - } - setState(() { - _uploadedFiles.addAll(files); - }); - } catch (e) { - setState(() { - _error = 'Erreur lors de l\'upload : $e'; - }); - } finally { - setState(() => _isLoading = false); - } - } - } - - Future moveEventFileHttp({ - required String sourcePath, - required String destinationPath, - }) async { - final url = Uri.parse( - 'https://us-central1-em2rp-951dc.cloudfunctions.net/moveEventFileV2'); - final user = FirebaseAuth.instance.currentUser; - final idToken = await user?.getIdToken(); - final response = await http.post( - url, - headers: { - 'Content-Type': 'application/json', - if (idToken != null) 'Authorization': 'Bearer $idToken', - }, - body: jsonEncode({ - 'data': { - 'sourcePath': sourcePath, - 'destinationPath': destinationPath, - } - }), - ); - if (response.statusCode == 200) { - final data = jsonDecode(response.body); - if (data['url'] != null) { - return data['url'] as String; - } else if (data['result'] != null && data['result']['url'] != null) { - return data['result']['url'] as String; - } - return null; - } else { - print('Erreur Cloud Function: \\n${response.body}'); - return null; - } - } - - Future _submit() async { - if (!_formKey.currentState!.validate() || - _startDateTime == null || - _endDateTime == null || - _selectedEventType == null || - _addressController.text.isEmpty) return; - if (_endDateTime!.isBefore(_startDateTime!) || - _endDateTime!.isAtSameMomentAs(_startDateTime!)) { - setState(() { - _error = "La date de fin doit être postérieure à la date de début."; - }); - return; - } - setState(() { - _isLoading = true; - _error = null; - _success = null; - }); - try { - final eventProvider = Provider.of(context, listen: false); - final newEvent = EventModel( - id: '', - name: _nameController.text.trim(), - description: _descriptionController.text.trim(), - startDateTime: _startDateTime!, - endDateTime: _endDateTime!, - basePrice: double.tryParse(_basePriceController.text) ?? 0.0, - installationTime: int.tryParse(_installationController.text) ?? 0, - disassemblyTime: int.tryParse(_disassemblyController.text) ?? 0, - eventTypeId: _selectedEventType!, - customerId: '', - address: _addressController.text.trim(), - workforce: _selectedUserIds - .map((id) => FirebaseFirestore.instance.collection('users').doc(id)) - .toList(), - latitude: 0.0, - longitude: 0.0, - documents: _uploadedFiles, - options: _selectedOptions - .map((opt) => { - 'name': opt['name'], - 'price': opt['price'], - }) - .toList(), - status: _selectedStatus, - ); - final docRef = await FirebaseFirestore.instance - .collection('events') - .add(newEvent.toMap()); - final eventId = docRef.id; - List> newFiles = []; - for (final file in _uploadedFiles) { - final fileName = file['name']!; - final oldUrl = file['url']!; - String sourcePath; - final tempPattern = RegExp(r'events/temp/[^?]+'); - final match = tempPattern.firstMatch(oldUrl); - if (match != null) { - sourcePath = match.group(0)!; - } else { - final tempFileName = - Uri.decodeComponent(oldUrl.split('/').last.split('?').first); - sourcePath = tempFileName; - } - final destinationPath = 'events/$eventId/$fileName'; - final newUrl = await moveEventFileHttp( - sourcePath: sourcePath, - destinationPath: destinationPath, - ); - if (newUrl != null) { - newFiles.add({'name': fileName, 'url': newUrl}); - } else { - newFiles.add({'name': fileName, 'url': oldUrl}); - } - } - await docRef.update({'documents': newFiles}); - final localUserProvider = - Provider.of(context, listen: false); - final userId = localUserProvider.uid; - final canViewAllEvents = - localUserProvider.hasPermission('view_all_events'); - if (userId != null) { - await eventProvider.loadUserEvents(userId, - canViewAllEvents: canViewAllEvents); - } - setState(() { - _success = "Événement créé avec succès !"; - }); - if (context.mounted) Navigator.of(context).pop(); - } catch (e) { - setState(() { - _error = "Erreur lors de la création : $e"; - }); - } finally { - setState(() { - _isLoading = false; - }); - } - } - - Widget _buildSectionTitle(String title) { - return Padding( - padding: const EdgeInsets.only(top: 16.0, bottom: 8.0), - child: Align( - alignment: Alignment.centerLeft, - child: Text( - title, - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - ), - ), - ); - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return WillPopScope( - onWillPop: _onWillPop, - child: Scaffold( - appBar: AppBar( - title: const Text('Créer un événement'), - ), - body: Center( - child: SingleChildScrollView( - child: Card( - elevation: 6, - margin: const EdgeInsets.all(24), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(18)), - child: Padding( - padding: - const EdgeInsets.symmetric(horizontal: 32, vertical: 32), - child: Form( - key: _formKey, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Padding( - padding: const EdgeInsets.only(top: 0.0, bottom: 4.0), - child: Align( - alignment: Alignment.centerLeft, - child: Text( - 'Informations principales', - style: const TextStyle( - fontSize: 18, fontWeight: FontWeight.bold), - ), - ), - ), - TextFormField( - controller: _nameController, - decoration: const InputDecoration( - labelText: 'Nom de l\'événement', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.event), - ), - validator: (v) => - v == null || v.isEmpty ? 'Champ requis' : null, - ), - const SizedBox(height: 16), - DropdownButtonFormField( - value: _selectedEventType, - items: _eventTypes - .map((type) => DropdownMenuItem( - value: type, - child: Text(type), - )) - .toList(), - onChanged: _onEventTypeChanged, - decoration: const InputDecoration( - labelText: 'Type d\'événement', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.category), - ), - validator: (v) => - v == null ? 'Sélectionnez un type' : null, - ), - const SizedBox(height: 16), - Row( - children: [ - Expanded( - child: GestureDetector( - onTap: () async { - final picked = await showDatePicker( - context: context, - initialDate: DateTime.now(), - firstDate: DateTime(2020), - lastDate: DateTime(2099), - ); - if (picked != null) { - final time = await showTimePicker( - context: context, - initialTime: TimeOfDay.now(), - ); - if (time != null) { - setState(() { - _startDateTime = DateTime( - picked.year, - picked.month, - picked.day, - time.hour, - time.minute, - ); - if (_endDateTime != null && - (_endDateTime! - .isBefore(_startDateTime!) || - _endDateTime!.isAtSameMomentAs( - _startDateTime!))) { - _endDateTime = null; - } - }); - } - } - }, - child: AbsorbPointer( - child: TextFormField( - readOnly: true, - decoration: InputDecoration( - labelText: 'Début', - border: const OutlineInputBorder(), - prefixIcon: - const Icon(Icons.calendar_today), - suffixIcon: const Icon(Icons.edit_calendar), - ), - controller: TextEditingController( - text: _startDateTime == null - ? '' - : DateFormat('dd/MM/yyyy HH:mm') - .format(_startDateTime!), - ), - validator: (v) => _startDateTime == null - ? 'Champ requis' - : null, - ), - ), - ), - ), - const SizedBox(width: 16), - Expanded( - child: GestureDetector( - onTap: _startDateTime == null - ? null - : () async { - final picked = await showDatePicker( - context: context, - initialDate: _startDateTime! - .add(const Duration(hours: 1)), - firstDate: _startDateTime!, - lastDate: DateTime(2099), - ); - if (picked != null) { - final time = await showTimePicker( - context: context, - initialTime: TimeOfDay.now(), - ); - if (time != null) { - setState(() { - _endDateTime = DateTime( - picked.year, - picked.month, - picked.day, - time.hour, - time.minute, - ); - }); - } - } - }, - child: AbsorbPointer( - child: TextFormField( - readOnly: true, - decoration: InputDecoration( - labelText: 'Fin', - border: const OutlineInputBorder(), - prefixIcon: - const Icon(Icons.calendar_today), - suffixIcon: const Icon(Icons.edit_calendar), - ), - controller: TextEditingController( - text: _endDateTime == null - ? '' - : DateFormat('dd/MM/yyyy HH:mm') - .format(_endDateTime!), - ), - validator: (v) => _endDateTime == null - ? 'Champ requis' - : (_startDateTime != null && - _endDateTime != null && - (_endDateTime!.isBefore( - _startDateTime!) || - _endDateTime! - .isAtSameMomentAs( - _startDateTime!))) - ? 'La date de fin doit être après la date de début' - : null, - ), - ), - ), - ), - ], - ), - const SizedBox(height: 16), - TextFormField( - controller: _basePriceController, - decoration: const InputDecoration( - labelText: 'Prix de base (€)', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.euro), - hintText: '1050.50', - ), - keyboardType: const TextInputType.numberWithOptions( - decimal: true), - inputFormatters: [ - FilteringTextInputFormatter.allow( - RegExp(r'^\d*\.?\d{0,2}')), - ], - validator: (value) { - if (value == null || value.isEmpty) { - return 'Le prix de base est requis'; - } - final price = - double.tryParse(value.replaceAll(',', '.')); - if (price == null) { - return 'Veuillez entrer un nombre valide'; - } - return null; - }, - onChanged: (_) => _onAnyFieldChanged(), - ), - const SizedBox(height: 16), - OptionSelectorWidget( - eventType: _selectedEventType, - selectedOptions: _selectedOptions, - onChanged: (opts) => - setState(() => _selectedOptions = opts), - onRemove: (name) { - setState(() { - _selectedOptions - .removeWhere((o) => o['name'] == name); - }); - }, - eventTypeRequired: _selectedEventType == null, - ), - _buildSectionTitle('Détails'), - AnimatedContainer( - duration: const Duration(milliseconds: 200), - constraints: BoxConstraints( - minHeight: 48, - maxHeight: 48.0 * 10, - ), - child: TextFormField( - controller: _descriptionController, - minLines: 1, - maxLines: _descriptionMaxLines > 10 - ? 10 - : _descriptionMaxLines, - decoration: const InputDecoration( - labelText: 'Description', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.description), - ), - ), - ), - const SizedBox(height: 20), - Row( - children: [ - Expanded( - child: IntStepperField( - label: 'Installation (h)', - controller: _installationController, - min: 0, - max: 99, - ), - ), - const SizedBox(width: 16), - Expanded( - child: IntStepperField( - label: 'Démontage (h)', - controller: _disassemblyController, - min: 0, - max: 99, - ), - ), - ], - ), - _buildSectionTitle('Adresse'), - TextFormField( - controller: _addressController, - decoration: const InputDecoration( - labelText: 'Adresse', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.location_on), - ), - validator: (v) => - v == null || v.isEmpty ? 'Champ requis' : null, - ), - _buildSectionTitle('Personnel'), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: UserMultiSelectWidget( - allUsers: _allUsers, - selectedUserIds: _selectedUserIds, - onChanged: (ids) => - setState(() => _selectedUserIds = ids), - isLoading: _isLoadingUsers, - ), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildSectionTitle('Documents'), - DropzoneUploadWidget( - uploadedFiles: _uploadedFiles, - onFilesChanged: (files) => - setState(() => _uploadedFiles = files), - isLoading: _isLoading, - error: _error, - success: _success, - ), - ], - ), - ), - ], - ), - const SizedBox(height: 24), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton( - onPressed: _isLoading - ? null - : () async { - final shouldLeave = await _onWillPop(); - if (shouldLeave && context.mounted) { - Navigator.of(context).pop(); - } - }, - child: const Text('Annuler'), - ), - const SizedBox(width: 8), - ElevatedButton.icon( - icon: const Icon(Icons.check), - onPressed: _isLoading ? null : _submit, - label: _isLoading - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2), - ) - : const Text('Créer'), - ), - ], - ), - const SizedBox(height: 16), - Center( - child: ElevatedButton.icon( - icon: const Icon(Icons.check_circle, - color: Colors.white), - label: const Text( - 'Définir cet événement comme confirmé'), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.green, - foregroundColor: Colors.white, - textStyle: - const TextStyle(fontWeight: FontWeight.bold), - ), - onPressed: null, - ), - ), - ], - ), - ), - ), - ), - ), - ), - ), - ); - } -} diff --git a/em2rp/lib/views/widgets/inputs/option_selector_widget.dart b/em2rp/lib/views/widgets/inputs/option_selector_widget.dart index bb3983e..c6db444 100644 --- a/em2rp/lib/views/widgets/inputs/option_selector_widget.dart +++ b/em2rp/lib/views/widgets/inputs/option_selector_widget.dart @@ -8,6 +8,7 @@ class OptionSelectorWidget extends StatefulWidget { final ValueChanged>> onChanged; final void Function(String name)? onRemove; final bool eventTypeRequired; + final bool isMobile; const OptionSelectorWidget({ super.key, @@ -16,6 +17,7 @@ class OptionSelectorWidget extends StatefulWidget { required this.onChanged, this.onRemove, this.eventTypeRequired = false, + this.isMobile = false, }); @override @@ -79,53 +81,56 @@ class _OptionSelectorWidgetState extends State { Text('Options sélectionnées', style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: 8), - Wrap( - spacing: 12, - runSpacing: 12, + Column( children: widget.selectedOptions - .map((opt) => SizedBox( - width: 260, - child: Card( - elevation: 2, - child: Padding( - padding: const EdgeInsets.all(12.0), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(opt['name'] ?? '', - style: const TextStyle( - fontWeight: FontWeight.bold)), - const SizedBox(height: 4), - Text(opt['details'] ?? '', - style: const TextStyle(fontSize: 13)), - const SizedBox(height: 4), - Text('Prix : ${opt['price'] ?? ''} €', - style: const TextStyle(fontSize: 13)), - ], - ), + .map((opt) => Card( + elevation: widget.isMobile ? 0 : 2, + margin: EdgeInsets.symmetric( + vertical: widget.isMobile ? 4 : 8, + horizontal: widget.isMobile ? 0 : 8), + shape: RoundedRectangleBorder( + borderRadius: + BorderRadius.circular(widget.isMobile ? 8 : 12)), + child: Padding( + padding: EdgeInsets.all(widget.isMobile ? 8.0 : 12.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(opt['name'] ?? '', + style: const TextStyle( + fontWeight: FontWeight.bold)), + if (opt['details'] != null && + opt['details'] != '') + Padding( + padding: const EdgeInsets.only(top: 2.0), + child: Text(opt['details'], + style: const TextStyle(fontSize: 13)), + ), + Text('Prix : ${opt['price'] ?? ''} €', + style: const TextStyle(fontSize: 13)), + ], ), - IconButton( - icon: const Icon(Icons.delete), - tooltip: 'Supprimer cette option', - onPressed: () { - if (widget.onRemove != null) { - widget.onRemove!(opt['name'] as String); - } else { - final newList = - List>.from( - widget.selectedOptions) - ..removeWhere( - (o) => o['name'] == opt['name']); - widget.onChanged(newList); - } - }, - ), - ], - ), + ), + IconButton( + icon: const Icon(Icons.delete), + tooltip: 'Supprimer cette option', + onPressed: () { + if (widget.onRemove != null) { + widget.onRemove!(opt['name'] as String); + } else { + final newList = List>.from( + widget.selectedOptions) + ..removeWhere( + (o) => o['name'] == opt['name']); + widget.onChanged(newList); + } + }, + ), + ], ), ), ))