Ajout des options
This commit is contained in:
@ -7,7 +7,7 @@ class EventModel {
|
|||||||
final String description;
|
final String description;
|
||||||
final DateTime startDateTime;
|
final DateTime startDateTime;
|
||||||
final DateTime endDateTime;
|
final DateTime endDateTime;
|
||||||
final double price;
|
final double basePrice;
|
||||||
final int installationTime;
|
final int installationTime;
|
||||||
final int disassemblyTime;
|
final int disassemblyTime;
|
||||||
final String eventTypeId;
|
final String eventTypeId;
|
||||||
@ -17,6 +17,7 @@ class EventModel {
|
|||||||
final double longitude;
|
final double longitude;
|
||||||
final List<DocumentReference> workforce;
|
final List<DocumentReference> workforce;
|
||||||
final List<Map<String, String>> documents;
|
final List<Map<String, String>> documents;
|
||||||
|
final List<Map<String, dynamic>> options;
|
||||||
|
|
||||||
EventModel({
|
EventModel({
|
||||||
required this.id,
|
required this.id,
|
||||||
@ -24,7 +25,7 @@ class EventModel {
|
|||||||
required this.description,
|
required this.description,
|
||||||
required this.startDateTime,
|
required this.startDateTime,
|
||||||
required this.endDateTime,
|
required this.endDateTime,
|
||||||
required this.price,
|
required this.basePrice,
|
||||||
required this.installationTime,
|
required this.installationTime,
|
||||||
required this.disassemblyTime,
|
required this.disassemblyTime,
|
||||||
required this.eventTypeId,
|
required this.eventTypeId,
|
||||||
@ -34,6 +35,7 @@ class EventModel {
|
|||||||
required this.longitude,
|
required this.longitude,
|
||||||
required this.workforce,
|
required this.workforce,
|
||||||
required this.documents,
|
required this.documents,
|
||||||
|
this.options = const [],
|
||||||
});
|
});
|
||||||
|
|
||||||
factory EventModel.fromMap(Map<String, dynamic> map, String id) {
|
factory EventModel.fromMap(Map<String, dynamic> map, String id) {
|
||||||
@ -55,6 +57,16 @@ class EventModel {
|
|||||||
}
|
}
|
||||||
}).toList()
|
}).toList()
|
||||||
: <Map<String, String>>[];
|
: <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(
|
return EventModel(
|
||||||
id: id,
|
id: id,
|
||||||
name: map['Name'] ?? '',
|
name: map['Name'] ?? '',
|
||||||
@ -62,7 +74,7 @@ class EventModel {
|
|||||||
startDateTime: startTimestamp?.toDate() ?? DateTime.now(),
|
startDateTime: startTimestamp?.toDate() ?? DateTime.now(),
|
||||||
endDateTime: endTimestamp?.toDate() ??
|
endDateTime: endTimestamp?.toDate() ??
|
||||||
DateTime.now().add(const Duration(hours: 1)),
|
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,
|
installationTime: map['InstallationTime'] ?? 0,
|
||||||
disassemblyTime: map['DisassemblyTime'] ?? 0,
|
disassemblyTime: map['DisassemblyTime'] ?? 0,
|
||||||
eventTypeId: map['EventType'] is DocumentReference
|
eventTypeId: map['EventType'] is DocumentReference
|
||||||
@ -76,6 +88,7 @@ class EventModel {
|
|||||||
longitude: (map['Longitude'] ?? 0.0).toDouble(),
|
longitude: (map['Longitude'] ?? 0.0).toDouble(),
|
||||||
workforce: workforceRefs.whereType<DocumentReference>().toList(),
|
workforce: workforceRefs.whereType<DocumentReference>().toList(),
|
||||||
documents: docs,
|
documents: docs,
|
||||||
|
options: options,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -85,7 +98,7 @@ class EventModel {
|
|||||||
'Description': description,
|
'Description': description,
|
||||||
'StartDateTime': Timestamp.fromDate(startDateTime),
|
'StartDateTime': Timestamp.fromDate(startDateTime),
|
||||||
'EndDateTime': Timestamp.fromDate(endDateTime),
|
'EndDateTime': Timestamp.fromDate(endDateTime),
|
||||||
'Price': price,
|
'BasePrice': basePrice,
|
||||||
'InstallationTime': installationTime,
|
'InstallationTime': installationTime,
|
||||||
'DisassemblyTime': disassemblyTime,
|
'DisassemblyTime': disassemblyTime,
|
||||||
'EventType': eventTypeId,
|
'EventType': eventTypeId,
|
||||||
@ -96,6 +109,7 @@ class EventModel {
|
|||||||
'Longitude': longitude,
|
'Longitude': longitude,
|
||||||
'workforce': workforce,
|
'workforce': workforce,
|
||||||
'documents': documents,
|
'documents': documents,
|
||||||
|
'options': options,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
42
em2rp/lib/models/option_model.dart
Normal file
42
em2rp/lib/models/option_model.dart
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@ -19,6 +19,7 @@ import 'package:flutter/foundation.dart';
|
|||||||
import 'package:flutter_dropzone/flutter_dropzone.dart';
|
import 'package:flutter_dropzone/flutter_dropzone.dart';
|
||||||
import 'package:em2rp/views/widgets/inputs/dropzone_upload_widget.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/user_management/user_multi_select_widget.dart';
|
||||||
|
import 'package:em2rp/views/widgets/inputs/option_selector_widget.dart';
|
||||||
|
|
||||||
class EventAddPage extends StatefulWidget {
|
class EventAddPage extends StatefulWidget {
|
||||||
const EventAddPage({super.key});
|
const EventAddPage({super.key});
|
||||||
@ -31,7 +32,7 @@ class _EventAddPageState extends State<EventAddPage> {
|
|||||||
final _formKey = GlobalKey<FormState>();
|
final _formKey = GlobalKey<FormState>();
|
||||||
final TextEditingController _nameController = TextEditingController();
|
final TextEditingController _nameController = TextEditingController();
|
||||||
final TextEditingController _descriptionController = TextEditingController();
|
final TextEditingController _descriptionController = TextEditingController();
|
||||||
final TextEditingController _priceController = TextEditingController();
|
final TextEditingController _basePriceController = TextEditingController();
|
||||||
final TextEditingController _installationController = TextEditingController();
|
final TextEditingController _installationController = TextEditingController();
|
||||||
final TextEditingController _disassemblyController = TextEditingController();
|
final TextEditingController _disassemblyController = TextEditingController();
|
||||||
final TextEditingController _addressController = TextEditingController();
|
final TextEditingController _addressController = TextEditingController();
|
||||||
@ -49,6 +50,7 @@ class _EventAddPageState extends State<EventAddPage> {
|
|||||||
List<Map<String, String>> _uploadedFiles = [];
|
List<Map<String, String>> _uploadedFiles = [];
|
||||||
DropzoneViewController? _dropzoneController;
|
DropzoneViewController? _dropzoneController;
|
||||||
bool _isDropzoneHighlighted = false;
|
bool _isDropzoneHighlighted = false;
|
||||||
|
List<Map<String, dynamic>> _selectedOptions = [];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_nameController.dispose();
|
_nameController.dispose();
|
||||||
_descriptionController.dispose();
|
_descriptionController.dispose();
|
||||||
_priceController.dispose();
|
_basePriceController.dispose();
|
||||||
_installationController.dispose();
|
_installationController.dispose();
|
||||||
_disassemblyController.dispose();
|
_disassemblyController.dispose();
|
||||||
_addressController.dispose();
|
_addressController.dispose();
|
||||||
@ -181,7 +209,7 @@ class _EventAddPageState extends State<EventAddPage> {
|
|||||||
description: _descriptionController.text.trim(),
|
description: _descriptionController.text.trim(),
|
||||||
startDateTime: _startDateTime!,
|
startDateTime: _startDateTime!,
|
||||||
endDateTime: _endDateTime!,
|
endDateTime: _endDateTime!,
|
||||||
price: double.tryParse(_priceController.text) ?? 0.0,
|
basePrice: double.tryParse(_basePriceController.text) ?? 0.0,
|
||||||
installationTime: int.tryParse(_installationController.text) ?? 0,
|
installationTime: int.tryParse(_installationController.text) ?? 0,
|
||||||
disassemblyTime: int.tryParse(_disassemblyController.text) ?? 0,
|
disassemblyTime: int.tryParse(_disassemblyController.text) ?? 0,
|
||||||
eventTypeId: _selectedEventType!,
|
eventTypeId: _selectedEventType!,
|
||||||
@ -193,6 +221,12 @@ class _EventAddPageState extends State<EventAddPage> {
|
|||||||
latitude: 0.0,
|
latitude: 0.0,
|
||||||
longitude: 0.0,
|
longitude: 0.0,
|
||||||
documents: _uploadedFiles,
|
documents: _uploadedFiles,
|
||||||
|
options: _selectedOptions
|
||||||
|
.map((opt) => {
|
||||||
|
'name': opt['name'],
|
||||||
|
'price': opt['price'],
|
||||||
|
})
|
||||||
|
.toList(),
|
||||||
);
|
);
|
||||||
final docRef = await FirebaseFirestore.instance
|
final docRef = await FirebaseFirestore.instance
|
||||||
.collection('events')
|
.collection('events')
|
||||||
@ -313,8 +347,7 @@ class _EventAddPageState extends State<EventAddPage> {
|
|||||||
child: Text(type),
|
child: Text(type),
|
||||||
))
|
))
|
||||||
.toList(),
|
.toList(),
|
||||||
onChanged: (val) =>
|
onChanged: _onEventTypeChanged,
|
||||||
setState(() => _selectedEventType = val),
|
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Type d\'événement',
|
labelText: 'Type d\'événement',
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
@ -446,9 +479,9 @@ class _EventAddPageState extends State<EventAddPage> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: _priceController,
|
controller: _basePriceController,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Prix (€)',
|
labelText: 'Prix de base (€)',
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
prefixIcon: Icon(Icons.euro),
|
prefixIcon: Icon(Icons.euro),
|
||||||
hintText: '1050.50',
|
hintText: '1050.50',
|
||||||
@ -461,19 +494,30 @@ class _EventAddPageState extends State<EventAddPage> {
|
|||||||
],
|
],
|
||||||
validator: (value) {
|
validator: (value) {
|
||||||
if (value == null || value.isEmpty) {
|
if (value == null || value.isEmpty) {
|
||||||
return 'Le prix est requis';
|
return 'Le prix de base est requis';
|
||||||
}
|
}
|
||||||
final price =
|
final price =
|
||||||
double.tryParse(value.replaceAll(',', '.'));
|
double.tryParse(value.replaceAll(',', '.'));
|
||||||
if (price == null) {
|
if (price == null) {
|
||||||
return 'Veuillez entrer un nombre valide';
|
return 'Veuillez entrer un nombre valide';
|
||||||
}
|
}
|
||||||
if (price < 0) {
|
|
||||||
return 'Le prix ne peut pas être négatif';
|
|
||||||
}
|
|
||||||
return null;
|
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'),
|
_buildSectionTitle('Détails'),
|
||||||
AnimatedContainer(
|
AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 200),
|
duration: const Duration(milliseconds: 200),
|
||||||
|
@ -88,6 +88,11 @@ class EventDetails extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
Expanded(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
_buildInfoRow(
|
_buildInfoRow(
|
||||||
context,
|
context,
|
||||||
Icons.calendar_today,
|
Icons.calendar_today,
|
||||||
@ -103,9 +108,81 @@ class EventDetails extends StatelessWidget {
|
|||||||
_buildInfoRow(
|
_buildInfoRow(
|
||||||
context,
|
context,
|
||||||
Icons.euro,
|
Icons.euro,
|
||||||
'Prix',
|
'Prix de base',
|
||||||
currencyFormat.format(event.price),
|
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(
|
_buildInfoRow(
|
||||||
context,
|
context,
|
||||||
Icons.build,
|
Icons.build,
|
||||||
@ -154,8 +231,12 @@ class EventDetails extends StatelessWidget {
|
|||||||
if (event.documents.isNotEmpty) ...[
|
if (event.documents.isNotEmpty) ...[
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text('Documents',
|
Text('Documents',
|
||||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
style: Theme.of(context)
|
||||||
color: AppColors.noir, fontWeight: FontWeight.bold)),
|
.textTheme
|
||||||
|
.titleLarge
|
||||||
|
?.copyWith(
|
||||||
|
color: AppColors.noir,
|
||||||
|
fontWeight: FontWeight.bold)),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Column(
|
Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@ -218,6 +299,10 @@ class EventDetails extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -303,7 +388,7 @@ class _EventAddDialogState extends State<EventAddDialog> {
|
|||||||
description: _descriptionController.text.trim(),
|
description: _descriptionController.text.trim(),
|
||||||
startDateTime: _startDateTime!,
|
startDateTime: _startDateTime!,
|
||||||
endDateTime: _endDateTime!,
|
endDateTime: _endDateTime!,
|
||||||
price: double.tryParse(_priceController.text) ?? 0.0,
|
basePrice: double.tryParse(_priceController.text) ?? 0.0,
|
||||||
installationTime: int.tryParse(_installationController.text) ?? 0,
|
installationTime: int.tryParse(_installationController.text) ?? 0,
|
||||||
disassemblyTime: int.tryParse(_disassemblyController.text) ?? 0,
|
disassemblyTime: int.tryParse(_disassemblyController.text) ?? 0,
|
||||||
eventTypeId: '', // à adapter si tu veux gérer les types
|
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