diff --git a/em2rp/lib/models/event_model.dart b/em2rp/lib/models/event_model.dart index d6da472..afb5a3d 100644 --- a/em2rp/lib/models/event_model.dart +++ b/em2rp/lib/models/event_model.dart @@ -1,6 +1,36 @@ import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:latlong2/latlong.dart'; +enum EventStatus { + confirmed, + canceled, + waitingForApproval, +} + +String eventStatusToString(EventStatus status) { + switch (status) { + case EventStatus.confirmed: + return 'CONFIRMED'; + case EventStatus.canceled: + return 'CANCELED'; + case EventStatus.waitingForApproval: + default: + return 'WAITING_FOR_APPROVAL'; + } +} + +EventStatus eventStatusFromString(String? status) { + switch (status) { + case 'CONFIRMED': + return EventStatus.confirmed; + case 'CANCELED': + return EventStatus.canceled; + case 'WAITING_FOR_APPROVAL': + default: + return EventStatus.waitingForApproval; + } +} + class EventModel { final String id; final String name; @@ -18,6 +48,7 @@ class EventModel { final List workforce; final List> documents; final List> options; + final EventStatus status; EventModel({ required this.id, @@ -36,6 +67,7 @@ class EventModel { required this.workforce, required this.documents, this.options = const [], + this.status = EventStatus.waitingForApproval, }); factory EventModel.fromMap(Map map, String id) { @@ -49,8 +81,9 @@ class EventModel { if (e is Map) { return Map.from(e as Map); } else if (e is String) { - final fileName = - Uri.decodeComponent(e.split('/').last.split('?').first); + final fileName = Uri.decodeComponent( + e.split('/').last.split('?').first, + ); return {'name': fileName, 'url': e}; } else { return {}; @@ -89,6 +122,7 @@ class EventModel { workforce: workforceRefs.whereType().toList(), documents: docs, options: options, + status: eventStatusFromString(map['status'] as String?), ); } @@ -110,6 +144,7 @@ class EventModel { 'workforce': workforce, 'documents': documents, 'options': options, + 'status': eventStatusToString(status), }; } } diff --git a/em2rp/lib/views/pages/event_add_page.dart b/em2rp/lib/views/pages/event_add_page.dart index 59751f5..0d191c2 100644 --- a/em2rp/lib/views/pages/event_add_page.dart +++ b/em2rp/lib/views/pages/event_add_page.dart @@ -20,6 +20,8 @@ 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}); @@ -43,6 +45,11 @@ class _EventAddPageState extends State { 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 = []; @@ -51,12 +58,22 @@ class _EventAddPageState extends State { 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() { @@ -66,6 +83,14 @@ class _EventAddPageState extends State { }); } + void _onAnyFieldChanged() { + if (!_formChanged) { + setState(() { + _formChanged = true; + }); + } + } + Future _fetchUsers() async { final snapshot = await FirebaseFirestore.instance.collection('users').get(); setState(() { @@ -78,10 +103,18 @@ class _EventAddPageState extends State { void _onEventTypeChanged(String? newType) { if (newType == _selectedEventType) return; - final oldType = _selectedEventType; 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) { @@ -99,6 +132,7 @@ class _EventAddPageState extends State { } else { _selectedOptions.clear(); } + _onAnyFieldChanged(); }); } @@ -110,9 +144,56 @@ class _EventAddPageState extends State { _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); @@ -227,6 +308,7 @@ class _EventAddPageState extends State { 'price': opt['price'], }) .toList(), + status: _selectedStatus, ); final docRef = await FirebaseFirestore.instance .collection('events') @@ -298,337 +380,366 @@ class _EventAddPageState extends State { @override Widget build(BuildContext context) { final theme = Theme.of(context); - return 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), + 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), + 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, ), - 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), + 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, ), - 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( + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: GestureDetector( + onTap: () async { + final picked = await showDatePicker( context: context, - initialTime: TimeOfDay.now(), + initialDate: DateTime.now(), + firstDate: DateTime(2020), + lastDate: DateTime(2099), ); - 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; - } - }); + 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), + }, + 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, ), - 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', ), - const SizedBox(width: 16), - Expanded( - child: GestureDetector( - onTap: _startDateTime == null + 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 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, - ); - }); - } + final shouldLeave = await _onWillPop(); + if (shouldLeave && context.mounted) { + Navigator.of(context).pop(); } }, - 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, - ), - ), + child: const Text('Annuler'), ), - ), - ], - ), - const SizedBox(height: 16), - TextFormField( - controller: _basePriceController, - decoration: const InputDecoration( - labelText: 'Prix de base (€)', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.euro), - hintText: '1050.50', + 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'), + ), + ], ), - 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; - }, - ), - 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: 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, ), ), - ), - 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 - : () => 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'), - ), - ], - ), - ], + ], + ), ), ), ), diff --git a/em2rp/lib/views/widgets/calendar_widgets/event_details.dart b/em2rp/lib/views/widgets/calendar_widgets/event_details.dart index 925505a..8035bec 100644 --- a/em2rp/lib/views/widgets/calendar_widgets/event_details.dart +++ b/em2rp/lib/views/widgets/calendar_widgets/event_details.dart @@ -8,6 +8,7 @@ import 'package:em2rp/providers/event_provider.dart'; import 'package:latlong2/latlong.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:path/path.dart' as p; +import 'package:cloud_firestore/cloud_firestore.dart'; class EventDetails extends StatelessWidget { final EventModel event; @@ -80,13 +81,34 @@ class EventDetails extends StatelessWidget { ], ), const SizedBox(height: 16), - SelectableText( - event.name, - style: Theme.of(context).textTheme.headlineMedium?.copyWith( - color: AppColors.noir, - fontWeight: FontWeight.bold, - ), + Row( + children: [ + SelectableText( + event.name, + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + color: AppColors.noir, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(width: 12), + _buildStatusIcon(event.status), + ], ), + if (Provider.of(context, listen: false) + .hasPermission('change_event_status')) + Padding( + padding: const EdgeInsets.symmetric(vertical: 12.0), + child: _FirestoreStatusButton( + eventId: event.id, + currentStatus: event.status, + onStatusChanged: (newStatus) async { + await FirebaseFirestore.instance + .collection('events') + .doc(event.id) + .update({'status': eventStatusToString(newStatus)}); + }, + ), + ), const SizedBox(height: 16), Expanded( child: SingleChildScrollView( @@ -333,6 +355,34 @@ class EventDetails extends StatelessWidget { ), ); } + + Widget _buildStatusIcon(EventStatus status) { + Color color; + IconData icon; + String tooltip; + switch (status) { + case EventStatus.confirmed: + color = Colors.green; + icon = Icons.check_circle; + tooltip = 'Confirmé'; + break; + case EventStatus.canceled: + color = Colors.red; + icon = Icons.cancel; + tooltip = 'Annulé'; + break; + case EventStatus.waitingForApproval: + default: + color = Colors.amber; + icon = Icons.hourglass_empty; + tooltip = 'En attente de validation'; + break; + } + return Tooltip( + message: tooltip, + child: Icon(icon, color: color, size: 28), + ); + } } class EventAddDialog extends StatefulWidget { @@ -573,3 +623,134 @@ class _EventAddDialogState extends State { ); } } + +class _FirestoreStatusButton extends StatefulWidget { + final String eventId; + final EventStatus currentStatus; + final Future Function(EventStatus) onStatusChanged; + const _FirestoreStatusButton( + {required this.eventId, + required this.currentStatus, + required this.onStatusChanged}); + + @override + State<_FirestoreStatusButton> createState() => _FirestoreStatusButtonState(); +} + +class _FirestoreStatusButtonState extends State<_FirestoreStatusButton> { + late EventStatus _status; + bool _loading = false; + + @override + void initState() { + super.initState(); + _status = widget.currentStatus; + } + + Future changerStatut(EventStatus nouveau) async { + if (_status == nouveau) return; + setState(() => _loading = true); + await FirebaseFirestore.instance + .collection('events') + .doc(widget.eventId) + .update({'status': eventStatusToString(nouveau)}); + setState(() { + _status = nouveau; + _loading = false; + }); + } + + @override + Widget build(BuildContext context) { + String texte; + Color couleurFond; + List enfants = []; + switch (_status) { + case EventStatus.waitingForApproval: + texte = "En Attente"; + couleurFond = Colors.yellow.shade600; + enfants = [ + _buildIconButton(Icons.close, Colors.red, + () => changerStatut(EventStatus.canceled)), + _buildLabel(texte, couleurFond), + _buildIconButton(Icons.check, Colors.green, + () => changerStatut(EventStatus.confirmed)), + ]; + break; + case EventStatus.confirmed: + texte = "Confirmé"; + couleurFond = Colors.green; + enfants = [ + _buildIconButton(Icons.close, Colors.red, + () => changerStatut(EventStatus.canceled)), + _buildIconButton(Icons.hourglass_empty, Colors.yellow.shade700, + () => changerStatut(EventStatus.waitingForApproval)), + _buildLabel(texte, couleurFond), + ]; + break; + case EventStatus.canceled: + texte = "Annulé"; + couleurFond = Colors.red; + enfants = [ + _buildLabel(texte, couleurFond), + _buildIconButton(Icons.hourglass_empty, Colors.yellow.shade700, + () => changerStatut(EventStatus.waitingForApproval)), + _buildIconButton(Icons.check, Colors.green, + () => changerStatut(EventStatus.confirmed)), + ]; + break; + } + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.all(2), + decoration: const BoxDecoration( + color: Colors.transparent, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: enfants, + ), + ); + } + + Widget _buildLabel(String texte, Color couleur) { + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + margin: const EdgeInsets.symmetric(horizontal: 2), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: couleur, + borderRadius: BorderRadius.circular(6), + ), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: Text( + texte, + key: ValueKey(texte), + style: const TextStyle( + fontWeight: FontWeight.bold, color: Colors.white, fontSize: 13), + ), + ), + ); + } + + Widget _buildIconButton( + IconData icone, Color couleur, VoidCallback onPressed) { + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + margin: const EdgeInsets.symmetric(horizontal: 2), + decoration: BoxDecoration( + border: Border.all(color: couleur, width: 1.5), + borderRadius: BorderRadius.circular(6), + ), + child: IconButton( + icon: Icon(icone, color: couleur, size: 16), + onPressed: _loading ? null : onPressed, + splashRadius: 16, + tooltip: 'Changer statut', + padding: const EdgeInsets.all(4), + constraints: const BoxConstraints(minWidth: 28, minHeight: 28), + ), + ); + } +} diff --git a/em2rp/lib/views/widgets/calendar_widgets/month_view.dart b/em2rp/lib/views/widgets/calendar_widgets/month_view.dart index b8f5a51..cde069e 100644 --- a/em2rp/lib/views/widgets/calendar_widgets/month_view.dart +++ b/em2rp/lib/views/widgets/calendar_widgets/month_view.dart @@ -213,6 +213,27 @@ class MonthView extends StatelessWidget { Widget _buildEventItem( EventModel event, bool isSelected, DateTime currentDay) { + Color color; + Color textColor; + IconData icon; + switch (event.status) { + case EventStatus.confirmed: + color = Colors.green; + textColor = Colors.white; + icon = Icons.check; + break; + case EventStatus.canceled: + color = Colors.red; + textColor = Colors.white; + icon = Icons.close; + break; + case EventStatus.waitingForApproval: + default: + color = Colors.amber; + textColor = Colors.black; + icon = Icons.hourglass_empty; + break; + } return GestureDetector( onTap: () { onDaySelected(currentDay, currentDay); @@ -222,33 +243,40 @@ class MonthView extends StatelessWidget { margin: const EdgeInsets.only(bottom: 2), padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), decoration: BoxDecoration( - color: isSelected - ? Colors.white.withAlpha(51) - : AppColors.rouge.withAlpha(26), + color: isSelected ? color.withAlpha(220) : color.withOpacity(0.18), borderRadius: BorderRadius.circular(4), ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, children: [ - Text( - event.name, - style: TextStyle( - fontSize: 12, - color: isSelected ? Colors.white : AppColors.rouge, - fontWeight: FontWeight.bold, + Icon(icon, color: textColor, size: 16), + const SizedBox(width: 4), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + event.name, + style: TextStyle( + fontSize: 12, + color: textColor, + fontWeight: FontWeight.bold, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (CalendarUtils.isMultiDayEvent(event)) + Text( + 'Jour ${CalendarUtils.calculateDayNumber(event.startDateTime, currentDay)}/${CalendarUtils.calculateTotalDays(event)}', + style: TextStyle( + fontSize: 10, + color: textColor, + ), + maxLines: 1, + ), + ], ), - maxLines: 1, - overflow: TextOverflow.ellipsis, ), - if (CalendarUtils.isMultiDayEvent(event)) - Text( - 'Jour ${CalendarUtils.calculateDayNumber(event.startDateTime, event.startDateTime)}/${CalendarUtils.calculateTotalDays(event)}', - style: TextStyle( - fontSize: 10, - color: isSelected ? Colors.white : AppColors.rouge, - ), - maxLines: 1, - ), ], ), ), diff --git a/em2rp/lib/views/widgets/calendar_widgets/week_view.dart b/em2rp/lib/views/widgets/calendar_widgets/week_view.dart index d87c8a5..5fb93cd 100644 --- a/em2rp/lib/views/widgets/calendar_widgets/week_view.dart +++ b/em2rp/lib/views/widgets/calendar_widgets/week_view.dart @@ -272,36 +272,47 @@ class WeekView extends StatelessWidget { padding: const EdgeInsets.all(4), decoration: BoxDecoration( color: isSelected - ? AppColors.rouge.withAlpha(80) - : AppColors.rouge.withAlpha(26), + ? _getStatusColor(e.event.status).withAlpha(220) + : _getStatusColor(e.event.status).withOpacity(0.18), border: Border.all( - color: AppColors.rouge, + color: _getStatusColor(e.event.status), width: isSelected ? 3 : 1, ), borderRadius: BorderRadius.circular(4), ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, children: [ - Text( - e.event.name, - style: const TextStyle( - color: AppColors.rouge, - fontSize: 12, - fontWeight: FontWeight.bold, + Icon(_getStatusIcon(e.event.status), + color: _getStatusTextColor(e.event.status), + size: 16), + const SizedBox(width: 4), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + e.event.name, + style: TextStyle( + color: _getStatusTextColor(e.event.status), + fontSize: 12, + fontWeight: FontWeight.bold, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (CalendarUtils.isMultiDayEvent(e.event)) + Text( + 'Jour ${CalendarUtils.calculateDayNumber(e.event.startDateTime, weekStart.add(Duration(days: dayIdx)))}/${CalendarUtils.calculateTotalDays(e.event)}', + style: TextStyle( + color: _getStatusTextColor(e.event.status), + fontSize: 10, + ), + maxLines: 1, + ), + ], ), - maxLines: 1, - overflow: TextOverflow.ellipsis, ), - if (CalendarUtils.isMultiDayEvent(e.event)) - Text( - 'Jour ${CalendarUtils.calculateDayNumber(e.event.startDateTime, weekStart.add(Duration(days: dayIdx)))}/${CalendarUtils.calculateTotalDays(e.event)}', - style: const TextStyle( - color: AppColors.rouge, - fontSize: 10, - ), - maxLines: 1, - ), ], ), ), @@ -377,6 +388,41 @@ class WeekView extends StatelessWidget { bool _overlap(_PositionedEvent a, _PositionedEvent b) { return a.end.isAfter(b.start) && a.start.isBefore(b.end); } + + Color _getStatusColor(EventStatus status) { + switch (status) { + case EventStatus.confirmed: + return Colors.green; + case EventStatus.canceled: + return Colors.red; + case EventStatus.waitingForApproval: + default: + return Colors.amber; + } + } + + Color _getStatusTextColor(EventStatus status) { + switch (status) { + case EventStatus.confirmed: + case EventStatus.canceled: + return Colors.white; + case EventStatus.waitingForApproval: + default: + return Colors.black; + } + } + + IconData _getStatusIcon(EventStatus status) { + switch (status) { + case EventStatus.confirmed: + return Icons.check; + case EventStatus.canceled: + return Icons.close; + case EventStatus.waitingForApproval: + default: + return Icons.hourglass_empty; + } + } } class _PositionedEvent {