Ajout des options
This commit is contained in:
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