Ajout des options
This commit is contained in:
		| @@ -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<num>( | ||||
|                                   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<EventAddDialog> { | ||||
|         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 | ||||
|   | ||||
							
								
								
									
										462
									
								
								em2rp/lib/views/widgets/inputs/option_selector_widget.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										462
									
								
								em2rp/lib/views/widgets/inputs/option_selector_widget.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -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<Map<String, dynamic>> selectedOptions; | ||||
|   final ValueChanged<List<Map<String, dynamic>>> 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<OptionSelectorWidget> createState() => _OptionSelectorWidgetState(); | ||||
| } | ||||
|  | ||||
| class _OptionSelectorWidgetState extends State<OptionSelectorWidget> { | ||||
|   List<EventOption> _allOptions = []; | ||||
|   bool _loading = true; | ||||
|   String _search = ''; | ||||
|   final List<String> _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<void> _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<Map<String, dynamic>>( | ||||
|       context: context, | ||||
|       builder: (ctx) => _OptionPickerDialog( | ||||
|         allOptions: _allOptions, | ||||
|         eventType: widget.eventType, | ||||
|       ), | ||||
|     ); | ||||
|     if (selected != null) { | ||||
|       final newList = List<Map<String, dynamic>>.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<Map<String, dynamic>>.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<EventOption> 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<double>( | ||||
|                               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<bool>( | ||||
|                     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<FormState>(); | ||||
|   final _nameController = TextEditingController(); | ||||
|   final _detailsController = TextEditingController(); | ||||
|   final _minPriceController = TextEditingController(); | ||||
|   final _maxPriceController = TextEditingController(); | ||||
|   List<String> _selectedTypes = []; | ||||
|   final List<String> _allTypes = ['Bal', 'Mariage', 'Anniversaire']; | ||||
|   String? _error; | ||||
|   bool _checkingName = false; | ||||
|  | ||||
|   Future<bool> _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'), | ||||
|         ), | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
| } | ||||
		Reference in New Issue
	
	Block a user