Ajout des options

This commit is contained in:
2025-05-27 22:55:34 +02:00
parent 9489183b68
commit 50a38816d3
5 changed files with 787 additions and 140 deletions

View File

@ -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<DocumentReference> workforce;
final List<Map<String, String>> documents;
final List<Map<String, dynamic>> 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<String, dynamic> map, String id) {
@ -55,6 +57,16 @@ class EventModel {
}
}).toList()
: <Map<String, String>>[];
final optionsRaw = map['options'] ?? [];
final options = optionsRaw is List
? optionsRaw.map<Map<String, dynamic>>((e) {
if (e is Map) {
return Map<String, dynamic>.from(e as Map);
} else {
return {};
}
}).toList()
: <Map<String, dynamic>>[];
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<DocumentReference>().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,
};
}
}

View File

@ -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<DocumentReference> eventTypes;
EventOption({
required this.id,
required this.name,
required this.details,
required this.valMin,
required this.valMax,
required this.eventTypes,
});
factory EventOption.fromMap(Map<String, dynamic> 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<dynamic>? ?? [])
.whereType<DocumentReference>()
.toList(),
);
}
Map<String, dynamic> toMap() {
return {
'name': name,
'details': details,
'valMin': valMin,
'valMax': valMax,
'eventTypes': eventTypes,
};
}
}

View File

@ -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<EventAddPage> {
final _formKey = GlobalKey<FormState>();
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<EventAddPage> {
List<Map<String, String>> _uploadedFiles = [];
DropzoneViewController? _dropzoneController;
bool _isDropzoneHighlighted = false;
List<Map<String, dynamic>> _selectedOptions = [];
@override
void initState() {
@ -74,11 +76,37 @@ class _EventAddPageState extends State<EventAddPage> {
});
}
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<String>?;
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<EventAddPage> {
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<EventAddPage> {
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<EventAddPage> {
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<EventAddPage> {
),
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<EventAddPage> {
],
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),

View File

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

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