From 50a38816d3e00ba430d01224b7b3d798bbc2768a Mon Sep 17 00:00:00 2001 From: "PC-PAUL\\paulf" Date: Tue, 27 May 2025 22:55:34 +0200 Subject: [PATCH] Ajout des options --- em2rp/lib/models/event_model.dart | 22 +- em2rp/lib/models/option_model.dart | 42 ++ em2rp/lib/views/pages/event_add_page.dart | 66 ++- .../calendar_widgets/event_details.dart | 335 ++++++++----- .../inputs/option_selector_widget.dart | 462 ++++++++++++++++++ 5 files changed, 787 insertions(+), 140 deletions(-) create mode 100644 em2rp/lib/models/option_model.dart create mode 100644 em2rp/lib/views/widgets/inputs/option_selector_widget.dart diff --git a/em2rp/lib/models/event_model.dart b/em2rp/lib/models/event_model.dart index 21db097..d6da472 100644 --- a/em2rp/lib/models/event_model.dart +++ b/em2rp/lib/models/event_model.dart @@ -7,7 +7,7 @@ class EventModel { final String description; final DateTime startDateTime; final DateTime endDateTime; - final double price; + final double basePrice; final int installationTime; final int disassemblyTime; final String eventTypeId; @@ -17,6 +17,7 @@ class EventModel { final double longitude; final List workforce; final List> documents; + final List> options; EventModel({ required this.id, @@ -24,7 +25,7 @@ class EventModel { required this.description, required this.startDateTime, required this.endDateTime, - required this.price, + required this.basePrice, required this.installationTime, required this.disassemblyTime, required this.eventTypeId, @@ -34,6 +35,7 @@ class EventModel { required this.longitude, required this.workforce, required this.documents, + this.options = const [], }); factory EventModel.fromMap(Map map, String id) { @@ -55,6 +57,16 @@ class EventModel { } }).toList() : >[]; + final optionsRaw = map['options'] ?? []; + final options = optionsRaw is List + ? optionsRaw.map>((e) { + if (e is Map) { + return Map.from(e as Map); + } else { + return {}; + } + }).toList() + : >[]; return EventModel( id: id, name: map['Name'] ?? '', @@ -62,7 +74,7 @@ class EventModel { startDateTime: startTimestamp?.toDate() ?? DateTime.now(), endDateTime: endTimestamp?.toDate() ?? DateTime.now().add(const Duration(hours: 1)), - price: (map['Price'] ?? 0.0).toDouble(), + basePrice: (map['BasePrice'] ?? map['Price'] ?? 0.0).toDouble(), installationTime: map['InstallationTime'] ?? 0, disassemblyTime: map['DisassemblyTime'] ?? 0, eventTypeId: map['EventType'] is DocumentReference @@ -76,6 +88,7 @@ class EventModel { longitude: (map['Longitude'] ?? 0.0).toDouble(), workforce: workforceRefs.whereType().toList(), documents: docs, + options: options, ); } @@ -85,7 +98,7 @@ class EventModel { 'Description': description, 'StartDateTime': Timestamp.fromDate(startDateTime), 'EndDateTime': Timestamp.fromDate(endDateTime), - 'Price': price, + 'BasePrice': basePrice, 'InstallationTime': installationTime, 'DisassemblyTime': disassemblyTime, 'EventType': eventTypeId, @@ -96,6 +109,7 @@ class EventModel { 'Longitude': longitude, 'workforce': workforce, 'documents': documents, + 'options': options, }; } } diff --git a/em2rp/lib/models/option_model.dart b/em2rp/lib/models/option_model.dart new file mode 100644 index 0000000..ccf4e88 --- /dev/null +++ b/em2rp/lib/models/option_model.dart @@ -0,0 +1,42 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; + +class EventOption { + final String id; + final String name; + final String details; + final double valMin; + final double valMax; + final List eventTypes; + + EventOption({ + required this.id, + required this.name, + required this.details, + required this.valMin, + required this.valMax, + required this.eventTypes, + }); + + factory EventOption.fromMap(Map map, String id) { + return EventOption( + id: id, + name: map['name'] ?? '', + details: map['details'] ?? '', + valMin: (map['valMin'] ?? 0.0).toDouble(), + valMax: (map['valMax'] ?? 0.0).toDouble(), + eventTypes: (map['eventTypes'] as List? ?? []) + .whereType() + .toList(), + ); + } + + Map toMap() { + return { + 'name': name, + 'details': details, + 'valMin': valMin, + 'valMax': valMax, + 'eventTypes': eventTypes, + }; + } +} diff --git a/em2rp/lib/views/pages/event_add_page.dart b/em2rp/lib/views/pages/event_add_page.dart index 05cebe2..59751f5 100644 --- a/em2rp/lib/views/pages/event_add_page.dart +++ b/em2rp/lib/views/pages/event_add_page.dart @@ -19,6 +19,7 @@ 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'; class EventAddPage extends StatefulWidget { const EventAddPage({super.key}); @@ -31,7 +32,7 @@ class _EventAddPageState extends State { final _formKey = GlobalKey(); final TextEditingController _nameController = TextEditingController(); final TextEditingController _descriptionController = TextEditingController(); - final TextEditingController _priceController = TextEditingController(); + final TextEditingController _basePriceController = TextEditingController(); final TextEditingController _installationController = TextEditingController(); final TextEditingController _disassemblyController = TextEditingController(); final TextEditingController _addressController = TextEditingController(); @@ -49,6 +50,7 @@ class _EventAddPageState extends State { List> _uploadedFiles = []; DropzoneViewController? _dropzoneController; bool _isDropzoneHighlighted = false; + List> _selectedOptions = []; @override void initState() { @@ -74,11 +76,37 @@ class _EventAddPageState extends State { }); } + void _onEventTypeChanged(String? newType) { + if (newType == _selectedEventType) return; + final oldType = _selectedEventType; + setState(() { + _selectedEventType = newType; + if (newType != null) { + // 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(); + } + }); + } + @override void dispose() { _nameController.dispose(); _descriptionController.dispose(); - _priceController.dispose(); + _basePriceController.dispose(); _installationController.dispose(); _disassemblyController.dispose(); _addressController.dispose(); @@ -181,7 +209,7 @@ class _EventAddPageState extends State { description: _descriptionController.text.trim(), startDateTime: _startDateTime!, endDateTime: _endDateTime!, - price: double.tryParse(_priceController.text) ?? 0.0, + basePrice: double.tryParse(_basePriceController.text) ?? 0.0, installationTime: int.tryParse(_installationController.text) ?? 0, disassemblyTime: int.tryParse(_disassemblyController.text) ?? 0, eventTypeId: _selectedEventType!, @@ -193,6 +221,12 @@ class _EventAddPageState extends State { latitude: 0.0, longitude: 0.0, documents: _uploadedFiles, + options: _selectedOptions + .map((opt) => { + 'name': opt['name'], + 'price': opt['price'], + }) + .toList(), ); final docRef = await FirebaseFirestore.instance .collection('events') @@ -313,8 +347,7 @@ class _EventAddPageState extends State { child: Text(type), )) .toList(), - onChanged: (val) => - setState(() => _selectedEventType = val), + onChanged: _onEventTypeChanged, decoration: const InputDecoration( labelText: 'Type d\'événement', border: OutlineInputBorder(), @@ -446,9 +479,9 @@ class _EventAddPageState extends State { ), const SizedBox(height: 16), TextFormField( - controller: _priceController, + controller: _basePriceController, decoration: const InputDecoration( - labelText: 'Prix (€)', + labelText: 'Prix de base (€)', border: OutlineInputBorder(), prefixIcon: Icon(Icons.euro), hintText: '1050.50', @@ -461,19 +494,30 @@ class _EventAddPageState extends State { ], validator: (value) { if (value == null || value.isEmpty) { - return 'Le prix est requis'; + return 'Le prix de base est requis'; } final price = double.tryParse(value.replaceAll(',', '.')); if (price == null) { return 'Veuillez entrer un nombre valide'; } - if (price < 0) { - return 'Le prix ne peut pas être négatif'; - } 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), diff --git a/em2rp/lib/views/widgets/calendar_widgets/event_details.dart b/em2rp/lib/views/widgets/calendar_widgets/event_details.dart index 472ea26..925505a 100644 --- a/em2rp/lib/views/widgets/calendar_widgets/event_details.dart +++ b/em2rp/lib/views/widgets/calendar_widgets/event_details.dart @@ -88,133 +88,218 @@ class EventDetails extends StatelessWidget { ), ), const SizedBox(height: 16), - _buildInfoRow( - context, - Icons.calendar_today, - 'Date de début', - dateFormat.format(event.startDateTime), - ), - _buildInfoRow( - context, - Icons.calendar_today, - 'Date de fin', - dateFormat.format(event.endDateTime), - ), - _buildInfoRow( - context, - Icons.euro, - 'Prix', - currencyFormat.format(event.price), - ), - _buildInfoRow( - context, - Icons.build, - 'Temps d\'installation', - '${event.installationTime} heures', - ), - _buildInfoRow( - context, - Icons.construction, - 'Temps de démontage', - '${event.disassemblyTime} heures', - ), - const SizedBox(height: 16), - Text( - 'Description', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - color: AppColors.noir, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - SelectableText( - event.description, - style: Theme.of(context).textTheme.bodyLarge, - ), - const SizedBox(height: 16), - Text( - 'Adresse', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - color: AppColors.noir, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - SelectableText( - event.address, - style: Theme.of(context).textTheme.bodyLarge, - ), - if (event.latitude != 0.0 || event.longitude != 0.0) ...[ - const SizedBox(height: 4), - SelectableText( - '${event.latitude}° N, ${event.longitude}° E', - style: Theme.of(context).textTheme.bodySmall, - ), - ], - if (event.documents.isNotEmpty) ...[ - const SizedBox(height: 16), - Text('Documents', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - color: AppColors.noir, fontWeight: FontWeight.bold)), - const SizedBox(height: 8), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: event.documents.map((doc) { - final fileName = doc['name'] ?? ''; - final url = doc['url'] ?? ''; - final ext = p.extension(fileName).toLowerCase(); - IconData icon; - if ([".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"] - .contains(ext)) { - icon = Icons.image; - } else if (ext == ".pdf") { - icon = Icons.picture_as_pdf; - } else if ([ - ".txt", - ".md", - ".csv", - ".json", - ".xml", - ".docx", - ".doc", - ".xls", - ".xlsx", - ".ppt", - ".pptx" - ].contains(ext)) { - icon = Icons.description; - } else { - icon = Icons.attach_file; - } - return ListTile( - leading: Icon(icon, color: Colors.blueGrey), - title: SelectableText( - fileName, - maxLines: 1, - textAlign: TextAlign.left, - style: Theme.of(context).textTheme.bodyMedium, + Expanded( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildInfoRow( + context, + Icons.calendar_today, + 'Date de début', + dateFormat.format(event.startDateTime), ), - trailing: IconButton( - icon: const Icon(Icons.download), - onPressed: () async { - if (await canLaunchUrl(Uri.parse(url))) { - await launchUrl(Uri.parse(url), - mode: LaunchMode.externalApplication); - } - }, + _buildInfoRow( + context, + Icons.calendar_today, + 'Date de fin', + dateFormat.format(event.endDateTime), ), - onTap: () async { - if (await canLaunchUrl(Uri.parse(url))) { - await launchUrl(Uri.parse(url), - mode: LaunchMode.externalApplication); - } - }, - contentPadding: EdgeInsets.zero, - dense: true, - ); - }).toList(), + _buildInfoRow( + context, + Icons.euro, + 'Prix de base', + currencyFormat.format(event.basePrice), + ), + if (event.options.isNotEmpty) ...[ + const SizedBox(height: 8), + Text('Options sélectionnées', + style: + Theme.of(context).textTheme.titleLarge?.copyWith( + color: AppColors.noir, + fontWeight: FontWeight.bold, + )), + const SizedBox(height: 4), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: event.options.map((opt) { + final price = (opt['price'] ?? 0.0) as num; + final isNegative = price < 0; + return ListTile( + leading: Icon(Icons.tune, + color: + isNegative ? Colors.red : AppColors.rouge), + title: Text(opt['name'] ?? '', + style: TextStyle(fontWeight: FontWeight.bold)), + subtitle: Text(opt['details'] ?? ''), + trailing: Text( + (isNegative ? '- ' : '+ ') + + currencyFormat.format(price.abs()), + style: TextStyle( + color: isNegative ? Colors.red : AppColors.noir, + fontWeight: FontWeight.bold, + ), + ), + contentPadding: EdgeInsets.zero, + dense: true, + ); + }).toList(), + ), + const SizedBox(height: 4), + Builder( + builder: (context) { + final total = event.basePrice + + event.options.fold( + 0, (sum, opt) => sum + (opt['price'] ?? 0.0)); + return Padding( + padding: + const EdgeInsets.only(top: 8.0, bottom: 8.0), + child: Row( + children: [ + const Icon(Icons.attach_money, + color: AppColors.rouge), + const SizedBox(width: 8), + Text('Prix total : ', + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith( + color: AppColors.noir, + fontWeight: FontWeight.bold, + )), + Text( + currencyFormat.format(total), + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith( + color: AppColors.rouge, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ); + }, + ), + ], + _buildInfoRow( + context, + Icons.build, + 'Temps d\'installation', + '${event.installationTime} heures', + ), + _buildInfoRow( + context, + Icons.construction, + 'Temps de démontage', + '${event.disassemblyTime} heures', + ), + const SizedBox(height: 16), + Text( + 'Description', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: AppColors.noir, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + SelectableText( + event.description, + style: Theme.of(context).textTheme.bodyLarge, + ), + const SizedBox(height: 16), + Text( + 'Adresse', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: AppColors.noir, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + SelectableText( + event.address, + style: Theme.of(context).textTheme.bodyLarge, + ), + if (event.latitude != 0.0 || event.longitude != 0.0) ...[ + const SizedBox(height: 4), + SelectableText( + '${event.latitude}° N, ${event.longitude}° E', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + if (event.documents.isNotEmpty) ...[ + const SizedBox(height: 16), + Text('Documents', + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith( + color: AppColors.noir, + fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: event.documents.map((doc) { + final fileName = doc['name'] ?? ''; + final url = doc['url'] ?? ''; + final ext = p.extension(fileName).toLowerCase(); + IconData icon; + if ([".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"] + .contains(ext)) { + icon = Icons.image; + } else if (ext == ".pdf") { + icon = Icons.picture_as_pdf; + } else if ([ + ".txt", + ".md", + ".csv", + ".json", + ".xml", + ".docx", + ".doc", + ".xls", + ".xlsx", + ".ppt", + ".pptx" + ].contains(ext)) { + icon = Icons.description; + } else { + icon = Icons.attach_file; + } + return ListTile( + leading: Icon(icon, color: Colors.blueGrey), + title: SelectableText( + fileName, + maxLines: 1, + textAlign: TextAlign.left, + style: Theme.of(context).textTheme.bodyMedium, + ), + trailing: IconButton( + icon: const Icon(Icons.download), + onPressed: () async { + if (await canLaunchUrl(Uri.parse(url))) { + await launchUrl(Uri.parse(url), + mode: LaunchMode.externalApplication); + } + }, + ), + onTap: () async { + if (await canLaunchUrl(Uri.parse(url))) { + await launchUrl(Uri.parse(url), + mode: LaunchMode.externalApplication); + } + }, + contentPadding: EdgeInsets.zero, + dense: true, + ); + }).toList(), + ), + ], + ], + ), ), - ], + ), ], ), ), @@ -303,7 +388,7 @@ class _EventAddDialogState extends State { description: _descriptionController.text.trim(), startDateTime: _startDateTime!, endDateTime: _endDateTime!, - price: double.tryParse(_priceController.text) ?? 0.0, + basePrice: double.tryParse(_priceController.text) ?? 0.0, installationTime: int.tryParse(_installationController.text) ?? 0, disassemblyTime: int.tryParse(_disassemblyController.text) ?? 0, eventTypeId: '', // à adapter si tu veux gérer les types diff --git a/em2rp/lib/views/widgets/inputs/option_selector_widget.dart b/em2rp/lib/views/widgets/inputs/option_selector_widget.dart new file mode 100644 index 0000000..bb3983e --- /dev/null +++ b/em2rp/lib/views/widgets/inputs/option_selector_widget.dart @@ -0,0 +1,462 @@ +import 'package:flutter/material.dart'; +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:em2rp/models/option_model.dart'; + +class OptionSelectorWidget extends StatefulWidget { + final String? eventType; + final List> selectedOptions; + final ValueChanged>> onChanged; + final void Function(String name)? onRemove; + final bool eventTypeRequired; + + const OptionSelectorWidget({ + super.key, + required this.eventType, + required this.selectedOptions, + required this.onChanged, + this.onRemove, + this.eventTypeRequired = false, + }); + + @override + State createState() => _OptionSelectorWidgetState(); +} + +class _OptionSelectorWidgetState extends State { + List _allOptions = []; + bool _loading = true; + String _search = ''; + final List _eventTypes = ['Bal', 'Mariage', 'Anniversaire']; + + @override + void didUpdateWidget(covariant OptionSelectorWidget oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.eventType != widget.eventType) { + _fetchOptions(); + } + } + + @override + void initState() { + super.initState(); + _fetchOptions(); + } + + Future _fetchOptions() async { + setState(() => _loading = true); + final snapshot = + await FirebaseFirestore.instance.collection('options').get(); + final options = snapshot.docs + .map((doc) => EventOption.fromMap(doc.data(), doc.id)) + .toList(); + setState(() { + _allOptions = options; + _loading = false; + }); + } + + void _showOptionPicker() async { + final selected = await showDialog>( + context: context, + builder: (ctx) => _OptionPickerDialog( + allOptions: _allOptions, + eventType: widget.eventType, + ), + ); + if (selected != null) { + final newList = List>.from(widget.selectedOptions) + ..add(selected); + widget.onChanged(newList); + } + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 8), + Text('Options sélectionnées', + style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + Wrap( + spacing: 12, + runSpacing: 12, + 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)), + ], + ), + ), + 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); + } + }, + ), + ], + ), + ), + ), + )) + .toList(), + ), + const SizedBox(height: 16), + Center( + child: ElevatedButton.icon( + icon: const Icon(Icons.add), + label: const Text('Ajouter une option'), + onPressed: + _loading || widget.eventTypeRequired ? null : _showOptionPicker, + ), + ), + ], + ); + } +} + +class _OptionPickerDialog extends StatefulWidget { + final List allOptions; + final String? eventType; + const _OptionPickerDialog( + {required this.allOptions, required this.eventType}); + + @override + State<_OptionPickerDialog> createState() => _OptionPickerDialogState(); +} + +class _OptionPickerDialogState extends State<_OptionPickerDialog> { + String _search = ''; + bool _creating = false; + + @override + Widget build(BuildContext context) { + final filtered = widget.allOptions.where((opt) { + if (widget.eventType == null) return false; + final matchesType = + opt.eventTypes.any((ref) => ref.id == widget.eventType); + final matchesSearch = + opt.name.toLowerCase().contains(_search.toLowerCase()); + return matchesType && matchesSearch; + }).toList(); + return Dialog( + child: SizedBox( + width: 400, + height: 500, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(12.0), + child: TextField( + decoration: const InputDecoration( + labelText: 'Rechercher une option', + prefixIcon: Icon(Icons.search), + ), + onChanged: (v) => setState(() => _search = v), + ), + ), + Expanded( + child: filtered.isEmpty + ? const Center( + child: Text( + 'Aucune option disponible pour ce type d\'événement.')) + : ListView.builder( + itemCount: filtered.length, + itemBuilder: (context, i) { + final opt = filtered[i]; + return ListTile( + title: Text(opt.name), + subtitle: Text(opt.details + + '\nFourchette: ${opt.valMin}€ ~ ${opt.valMax}€'), + onTap: () async { + final min = opt.valMin; + final max = opt.valMax; + final defaultPrice = + ((min + max) / 2).toStringAsFixed(2); + final price = await showDialog( + context: context, + builder: (ctx) { + final priceController = + TextEditingController(text: defaultPrice); + return AlertDialog( + title: Text('Prix pour ${opt.name}'), + content: TextField( + controller: priceController, + keyboardType: + const TextInputType.numberWithOptions( + decimal: true), + decoration: const InputDecoration( + labelText: 'Prix (€)'), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Annuler'), + ), + ElevatedButton( + onPressed: () { + final price = double.tryParse( + priceController.text + .replaceAll(',', '.')) ?? + 0.0; + Navigator.pop(ctx, price); + }, + child: const Text('Ajouter'), + ), + ], + ); + }, + ); + if (price != null) { + Navigator.pop(context, { + 'name': opt.name, + 'price': price, + 'compatibleTypes': opt.eventTypes + .map((ref) => ref.id) + .toList(), + }); + } + }, + ); + }, + ), + ), + const Divider(), + Padding( + padding: const EdgeInsets.only(bottom: 8.0, top: 4.0), + child: GestureDetector( + onTap: () async { + setState(() => _creating = true); + final created = await showDialog( + context: context, + builder: (ctx) => _CreateOptionDialog(), + ); + setState(() => _creating = false); + if (created == true) { + Navigator.pop(context); + } + }, + child: _creating + ? const Padding( + padding: EdgeInsets.all(8.0), + child: CircularProgressIndicator(), + ) + : const Text( + 'Ajouter une nouvelle option', + style: TextStyle( + color: Colors.blue, + decoration: TextDecoration.underline, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +class _CreateOptionDialog extends StatefulWidget { + @override + State<_CreateOptionDialog> createState() => _CreateOptionDialogState(); +} + +class _CreateOptionDialogState extends State<_CreateOptionDialog> { + final _formKey = GlobalKey(); + final _nameController = TextEditingController(); + final _detailsController = TextEditingController(); + final _minPriceController = TextEditingController(); + final _maxPriceController = TextEditingController(); + List _selectedTypes = []; + final List _allTypes = ['Bal', 'Mariage', 'Anniversaire']; + String? _error; + bool _checkingName = false; + + Future _isNameUnique(String name) async { + final snap = await FirebaseFirestore.instance + .collection('options') + .where('name', isEqualTo: name) + .get(); + return snap.docs.isEmpty; + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Créer une nouvelle option'), + content: SingleChildScrollView( + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextFormField( + controller: _nameController, + decoration: + const InputDecoration(labelText: 'Nom de l\'option'), + validator: (v) => + v == null || v.isEmpty ? 'Champ requis' : null, + ), + const SizedBox(height: 8), + TextFormField( + controller: _detailsController, + decoration: const InputDecoration(labelText: 'Détails'), + maxLines: 2, + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: TextFormField( + controller: _minPriceController, + decoration: + const InputDecoration(labelText: 'Prix min (€)'), + keyboardType: + const TextInputType.numberWithOptions(decimal: true), + validator: (v) => + v == null || v.isEmpty ? 'Obligatoire' : null, + ), + ), + const SizedBox(width: 8), + Expanded( + child: TextFormField( + controller: _maxPriceController, + decoration: + const InputDecoration(labelText: 'Prix max (€)'), + keyboardType: + const TextInputType.numberWithOptions(decimal: true), + validator: (v) => + v == null || v.isEmpty ? 'Obligatoire' : null, + ), + ), + ], + ), + const SizedBox(height: 8), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Types d\'événement associés :'), + Wrap( + spacing: 8, + children: _allTypes + .map((type) => FilterChip( + label: Text(type), + selected: _selectedTypes.contains(type), + onSelected: (selected) { + setState(() { + if (selected) { + _selectedTypes.add(type); + } else { + _selectedTypes.remove(type); + } + }); + }, + )) + .toList(), + ), + ], + ), + if (_error != null) + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: + Text(_error!, style: const TextStyle(color: Colors.red)), + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Annuler'), + ), + ElevatedButton( + onPressed: _checkingName + ? null + : () async { + if (!_formKey.currentState!.validate()) return; + if (_selectedTypes.isEmpty) { + setState(() => + _error = 'Sélectionnez au moins un type d\'événement'); + return; + } + final min = double.tryParse( + _minPriceController.text.replaceAll(',', '.')); + final max = double.tryParse( + _maxPriceController.text.replaceAll(',', '.')); + if (min == null || max == null) { + setState( + () => _error = 'Prix min et max doivent être valides'); + return; + } + final name = _nameController.text.trim(); + setState(() => _checkingName = true); + final unique = await _isNameUnique(name); + setState(() => _checkingName = false); + if (!unique) { + setState( + () => _error = 'Ce nom d\'option est déjà utilisé.'); + return; + } + final eventTypeRefs = _selectedTypes + .map((type) => FirebaseFirestore.instance + .collection('eventTypes') + .doc(type)) + .toList(); + try { + await FirebaseFirestore.instance.collection('options').add({ + 'name': name, + 'details': _detailsController.text.trim(), + 'valMin': min, + 'valMax': max, + 'eventTypes': eventTypeRefs, + }); + Navigator.pop(context, true); + } catch (e) { + setState(() => _error = 'Erreur lors de la création : $e'); + } + }, + child: _checkingName + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2)) + : const Text('Créer'), + ), + ], + ); + } +}