Ajout de statu d'événement (bouton non indéxé sur le status pour le moment

This commit is contained in:
2025-05-28 23:10:43 +02:00
parent 50a38816d3
commit b80a6d2623
5 changed files with 765 additions and 364 deletions

View File

@ -20,6 +20,8 @@ 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';
// ignore: avoid_web_libraries_in_flutter
import 'dart:html' as html;
class EventAddPage extends StatefulWidget {
const EventAddPage({super.key});
@ -43,6 +45,11 @@ class _EventAddPageState extends State<EventAddPage> {
String? _success;
String? _selectedEventType;
final List<String> _eventTypes = ['Bal', 'Mariage', 'Anniversaire'];
final Map<String, double> _eventTypeDefaultPrices = {
'Bal': 800.0,
'Mariage': 1500.0,
'Anniversaire': 500.0,
};
int _descriptionMaxLines = 3;
List<String> _selectedUserIds = [];
List<UserModel> _allUsers = [];
@ -51,12 +58,22 @@ class _EventAddPageState extends State<EventAddPage> {
DropzoneViewController? _dropzoneController;
bool _isDropzoneHighlighted = false;
List<Map<String, dynamic>> _selectedOptions = [];
bool _formChanged = false;
EventStatus _selectedStatus = EventStatus.waitingForApproval;
@override
void initState() {
super.initState();
_descriptionController.addListener(_handleDescriptionChange);
_fetchUsers();
_nameController.addListener(_onAnyFieldChanged);
_basePriceController.addListener(_onAnyFieldChanged);
_installationController.addListener(_onAnyFieldChanged);
_disassemblyController.addListener(_onAnyFieldChanged);
_addressController.addListener(_onAnyFieldChanged);
_descriptionController.addListener(_onAnyFieldChanged);
_addBeforeUnloadListener();
_selectedStatus = EventStatus.waitingForApproval;
}
void _handleDescriptionChange() {
@ -66,6 +83,14 @@ class _EventAddPageState extends State<EventAddPage> {
});
}
void _onAnyFieldChanged() {
if (!_formChanged) {
setState(() {
_formChanged = true;
});
}
}
Future<void> _fetchUsers() async {
final snapshot = await FirebaseFirestore.instance.collection('users').get();
setState(() {
@ -78,10 +103,18 @@ class _EventAddPageState extends State<EventAddPage> {
void _onEventTypeChanged(String? newType) {
if (newType == _selectedEventType) return;
final oldType = _selectedEventType;
setState(() {
_selectedEventType = newType;
if (newType != null) {
// Appliquer le prix par défaut si champ vide ou si type changé
final defaultPrice = _eventTypeDefaultPrices[newType] ?? 0.0;
if (_basePriceController.text.isEmpty ||
(_selectedEventType != null &&
_basePriceController.text ==
(_eventTypeDefaultPrices[_selectedEventType] ?? '')
.toString())) {
_basePriceController.text = defaultPrice.toStringAsFixed(2);
}
// Efface les options non compatibles
final before = _selectedOptions.length;
_selectedOptions.removeWhere((opt) {
@ -99,6 +132,7 @@ class _EventAddPageState extends State<EventAddPage> {
} else {
_selectedOptions.clear();
}
_onAnyFieldChanged();
});
}
@ -110,9 +144,56 @@ class _EventAddPageState extends State<EventAddPage> {
_installationController.dispose();
_disassemblyController.dispose();
_addressController.dispose();
_removeBeforeUnloadListener();
super.dispose();
}
// --- Web: beforeunload pour empêcher la fermeture sans confirmation ---
void _addBeforeUnloadListener() {
if (kIsWeb) {
html.window.onBeforeUnload.listen(_beforeUnloadHandler);
}
}
void _removeBeforeUnloadListener() {
if (kIsWeb) {
// Il n'est pas possible de retirer un listener anonyme, donc on ne fait rien ici.
// Pour une gestion plus fine, il faudrait stocker la référence du listener.
}
}
void _beforeUnloadHandler(html.Event event) {
if (_formChanged) {
event.preventDefault();
// Pour Chrome/Edge/Firefox, il faut définir returnValue
// ignore: unsafe_html
(event as dynamic).returnValue = '';
}
}
Future<bool> _onWillPop() async {
if (!_formChanged) return true;
final shouldLeave = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Quitter la page ?'),
content: const Text(
'Les modifications non enregistrées seront perdues. Voulez-vous vraiment quitter ?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Quitter'),
),
],
),
);
return shouldLeave ?? false;
}
Future<void> _pickAndUploadFiles() async {
final result = await FilePicker.platform
.pickFiles(allowMultiple: true, withData: true);
@ -227,6 +308,7 @@ class _EventAddPageState extends State<EventAddPage> {
'price': opt['price'],
})
.toList(),
status: _selectedStatus,
);
final docRef = await FirebaseFirestore.instance
.collection('events')
@ -298,337 +380,366 @@ class _EventAddPageState extends State<EventAddPage> {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: const Text('Créer un événement'),
),
body: Center(
child: SingleChildScrollView(
child: Card(
elevation: 6,
margin: const EdgeInsets.all(24),
shape:
RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 32),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.only(top: 0.0, bottom: 4.0),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
'Informations principales',
style: const TextStyle(
fontSize: 18, fontWeight: FontWeight.bold),
return WillPopScope(
onWillPop: _onWillPop,
child: Scaffold(
appBar: AppBar(
title: const Text('Créer un événement'),
),
body: Center(
child: SingleChildScrollView(
child: Card(
elevation: 6,
margin: const EdgeInsets.all(24),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(18)),
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 32, vertical: 32),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.only(top: 0.0, bottom: 4.0),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
'Informations principales',
style: const TextStyle(
fontSize: 18, fontWeight: FontWeight.bold),
),
),
),
),
TextFormField(
controller: _nameController,
decoration: const InputDecoration(
labelText: 'Nom de l\'événement',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.event),
TextFormField(
controller: _nameController,
decoration: const InputDecoration(
labelText: 'Nom de l\'événement',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.event),
),
validator: (v) =>
v == null || v.isEmpty ? 'Champ requis' : null,
),
validator: (v) =>
v == null || v.isEmpty ? 'Champ requis' : null,
),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
value: _selectedEventType,
items: _eventTypes
.map((type) => DropdownMenuItem<String>(
value: type,
child: Text(type),
))
.toList(),
onChanged: _onEventTypeChanged,
decoration: const InputDecoration(
labelText: 'Type d\'événement',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.category),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
value: _selectedEventType,
items: _eventTypes
.map((type) => DropdownMenuItem<String>(
value: type,
child: Text(type),
))
.toList(),
onChanged: _onEventTypeChanged,
decoration: const InputDecoration(
labelText: 'Type d\'événement',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.category),
),
validator: (v) =>
v == null ? 'Sélectionnez un type' : null,
),
validator: (v) =>
v == null ? 'Sélectionnez un type' : null,
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: GestureDetector(
onTap: () async {
final picked = await showDatePicker(
context: context,
initialDate: DateTime.now(),
firstDate: DateTime(2020),
lastDate: DateTime(2099),
);
if (picked != null) {
final time = await showTimePicker(
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: GestureDetector(
onTap: () async {
final picked = await showDatePicker(
context: context,
initialTime: TimeOfDay.now(),
initialDate: DateTime.now(),
firstDate: DateTime(2020),
lastDate: DateTime(2099),
);
if (time != null) {
setState(() {
_startDateTime = DateTime(
picked.year,
picked.month,
picked.day,
time.hour,
time.minute,
);
if (_endDateTime != null &&
(_endDateTime!
.isBefore(_startDateTime!) ||
_endDateTime!.isAtSameMomentAs(
_startDateTime!))) {
_endDateTime = null;
}
});
if (picked != null) {
final time = await showTimePicker(
context: context,
initialTime: TimeOfDay.now(),
);
if (time != null) {
setState(() {
_startDateTime = DateTime(
picked.year,
picked.month,
picked.day,
time.hour,
time.minute,
);
if (_endDateTime != null &&
(_endDateTime!
.isBefore(_startDateTime!) ||
_endDateTime!.isAtSameMomentAs(
_startDateTime!))) {
_endDateTime = null;
}
});
}
}
}
},
child: AbsorbPointer(
child: TextFormField(
readOnly: true,
decoration: InputDecoration(
labelText: 'Début',
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.calendar_today),
suffixIcon: const Icon(Icons.edit_calendar),
},
child: AbsorbPointer(
child: TextFormField(
readOnly: true,
decoration: InputDecoration(
labelText: 'Début',
border: const OutlineInputBorder(),
prefixIcon:
const Icon(Icons.calendar_today),
suffixIcon: const Icon(Icons.edit_calendar),
),
controller: TextEditingController(
text: _startDateTime == null
? ''
: DateFormat('dd/MM/yyyy HH:mm')
.format(_startDateTime!),
),
validator: (v) => _startDateTime == null
? 'Champ requis'
: null,
),
controller: TextEditingController(
text: _startDateTime == null
? ''
: DateFormat('dd/MM/yyyy HH:mm')
.format(_startDateTime!),
),
validator: (v) => _startDateTime == null
? 'Champ requis'
: null,
),
),
),
const SizedBox(width: 16),
Expanded(
child: GestureDetector(
onTap: _startDateTime == null
? null
: () async {
final picked = await showDatePicker(
context: context,
initialDate: _startDateTime!
.add(const Duration(hours: 1)),
firstDate: _startDateTime!,
lastDate: DateTime(2099),
);
if (picked != null) {
final time = await showTimePicker(
context: context,
initialTime: TimeOfDay.now(),
);
if (time != null) {
setState(() {
_endDateTime = DateTime(
picked.year,
picked.month,
picked.day,
time.hour,
time.minute,
);
});
}
}
},
child: AbsorbPointer(
child: TextFormField(
readOnly: true,
decoration: InputDecoration(
labelText: 'Fin',
border: const OutlineInputBorder(),
prefixIcon:
const Icon(Icons.calendar_today),
suffixIcon: const Icon(Icons.edit_calendar),
),
controller: TextEditingController(
text: _endDateTime == null
? ''
: DateFormat('dd/MM/yyyy HH:mm')
.format(_endDateTime!),
),
validator: (v) => _endDateTime == null
? 'Champ requis'
: (_startDateTime != null &&
_endDateTime != null &&
(_endDateTime!.isBefore(
_startDateTime!) ||
_endDateTime!
.isAtSameMomentAs(
_startDateTime!)))
? 'La date de fin doit être après la date de début'
: null,
),
),
),
),
],
),
const SizedBox(height: 16),
TextFormField(
controller: _basePriceController,
decoration: const InputDecoration(
labelText: 'Prix de base (€)',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.euro),
hintText: '1050.50',
),
const SizedBox(width: 16),
Expanded(
child: GestureDetector(
onTap: _startDateTime == null
keyboardType: const TextInputType.numberWithOptions(
decimal: true),
inputFormatters: [
FilteringTextInputFormatter.allow(
RegExp(r'^\d*\.?\d{0,2}')),
],
validator: (value) {
if (value == null || value.isEmpty) {
return 'Le prix de base est requis';
}
final price =
double.tryParse(value.replaceAll(',', '.'));
if (price == null) {
return 'Veuillez entrer un nombre valide';
}
return null;
},
onChanged: (_) => _onAnyFieldChanged(),
),
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),
constraints: BoxConstraints(
minHeight: 48,
maxHeight: 48.0 * 10,
),
child: TextFormField(
controller: _descriptionController,
minLines: 1,
maxLines: _descriptionMaxLines > 10
? 10
: _descriptionMaxLines,
decoration: const InputDecoration(
labelText: 'Description',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.description),
),
),
),
const SizedBox(height: 20),
Row(
children: [
Expanded(
child: IntStepperField(
label: 'Installation (h)',
controller: _installationController,
min: 0,
max: 99,
),
),
const SizedBox(width: 16),
Expanded(
child: IntStepperField(
label: 'Démontage (h)',
controller: _disassemblyController,
min: 0,
max: 99,
),
),
],
),
_buildSectionTitle('Adresse'),
TextFormField(
controller: _addressController,
decoration: const InputDecoration(
labelText: 'Adresse',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.location_on),
),
validator: (v) =>
v == null || v.isEmpty ? 'Champ requis' : null,
),
_buildSectionTitle('Personnel'),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: UserMultiSelectWidget(
allUsers: _allUsers,
selectedUserIds: _selectedUserIds,
onChanged: (ids) =>
setState(() => _selectedUserIds = ids),
isLoading: _isLoadingUsers,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionTitle('Documents'),
DropzoneUploadWidget(
uploadedFiles: _uploadedFiles,
onFilesChanged: (files) =>
setState(() => _uploadedFiles = files),
isLoading: _isLoading,
error: _error,
success: _success,
),
],
),
),
],
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: _isLoading
? null
: () async {
final picked = await showDatePicker(
context: context,
initialDate: _startDateTime!
.add(const Duration(hours: 1)),
firstDate: _startDateTime!,
lastDate: DateTime(2099),
);
if (picked != null) {
final time = await showTimePicker(
context: context,
initialTime: TimeOfDay.now(),
);
if (time != null) {
setState(() {
_endDateTime = DateTime(
picked.year,
picked.month,
picked.day,
time.hour,
time.minute,
);
});
}
final shouldLeave = await _onWillPop();
if (shouldLeave && context.mounted) {
Navigator.of(context).pop();
}
},
child: AbsorbPointer(
child: TextFormField(
readOnly: true,
decoration: InputDecoration(
labelText: 'Fin',
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.calendar_today),
suffixIcon: const Icon(Icons.edit_calendar),
),
controller: TextEditingController(
text: _endDateTime == null
? ''
: DateFormat('dd/MM/yyyy HH:mm')
.format(_endDateTime!),
),
validator: (v) => _endDateTime == null
? 'Champ requis'
: (_startDateTime != null &&
_endDateTime != null &&
(_endDateTime!.isBefore(
_startDateTime!) ||
_endDateTime!.isAtSameMomentAs(
_startDateTime!)))
? 'La date de fin doit être après la date de début'
: null,
),
),
child: const Text('Annuler'),
),
),
],
),
const SizedBox(height: 16),
TextFormField(
controller: _basePriceController,
decoration: const InputDecoration(
labelText: 'Prix de base (€)',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.euro),
hintText: '1050.50',
const SizedBox(width: 8),
ElevatedButton.icon(
icon: const Icon(Icons.check),
onPressed: _isLoading ? null : _submit,
label: _isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2),
)
: const Text('Créer'),
),
],
),
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [
FilteringTextInputFormatter.allow(
RegExp(r'^\d*\.?\d{0,2}')),
],
validator: (value) {
if (value == null || value.isEmpty) {
return 'Le prix de base est requis';
}
final price =
double.tryParse(value.replaceAll(',', '.'));
if (price == null) {
return 'Veuillez entrer un nombre valide';
}
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),
constraints: BoxConstraints(
minHeight: 48,
maxHeight: 48.0 * 10,
),
child: TextFormField(
controller: _descriptionController,
minLines: 1,
maxLines: _descriptionMaxLines > 10
? 10
: _descriptionMaxLines,
decoration: const InputDecoration(
labelText: 'Description',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.description),
const SizedBox(height: 16),
Center(
child: ElevatedButton.icon(
icon: const Icon(Icons.check_circle,
color: Colors.white),
label: const Text(
'Définir cet événement comme confirmé'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green,
foregroundColor: Colors.white,
textStyle:
const TextStyle(fontWeight: FontWeight.bold),
),
onPressed: null,
),
),
),
const SizedBox(height: 20),
Row(
children: [
Expanded(
child: IntStepperField(
label: 'Installation (h)',
controller: _installationController,
min: 0,
max: 99,
),
),
const SizedBox(width: 16),
Expanded(
child: IntStepperField(
label: 'Démontage (h)',
controller: _disassemblyController,
min: 0,
max: 99,
),
),
],
),
_buildSectionTitle('Adresse'),
TextFormField(
controller: _addressController,
decoration: const InputDecoration(
labelText: 'Adresse',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.location_on),
),
validator: (v) =>
v == null || v.isEmpty ? 'Champ requis' : null,
),
_buildSectionTitle('Personnel'),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: UserMultiSelectWidget(
allUsers: _allUsers,
selectedUserIds: _selectedUserIds,
onChanged: (ids) =>
setState(() => _selectedUserIds = ids),
isLoading: _isLoadingUsers,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionTitle('Documents'),
DropzoneUploadWidget(
uploadedFiles: _uploadedFiles,
onFilesChanged: (files) =>
setState(() => _uploadedFiles = files),
isLoading: _isLoading,
error: _error,
success: _success,
),
],
),
),
],
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: _isLoading
? null
: () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
const SizedBox(width: 8),
ElevatedButton.icon(
icon: const Icon(Icons.check),
onPressed: _isLoading ? null : _submit,
label: _isLoading
? const SizedBox(
width: 20,
height: 20,
child:
CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Créer'),
),
],
),
],
],
),
),
),
),