Vue mobile add event OK
This commit is contained in:
@ -10,7 +10,7 @@ import 'package:em2rp/views/widgets/calendar_widgets/event_details.dart';
|
|||||||
import 'package:intl/date_symbol_data_local.dart';
|
import 'package:intl/date_symbol_data_local.dart';
|
||||||
import 'package:em2rp/views/widgets/calendar_widgets/month_view.dart';
|
import 'package:em2rp/views/widgets/calendar_widgets/month_view.dart';
|
||||||
import 'package:em2rp/views/widgets/calendar_widgets/week_view.dart';
|
import 'package:em2rp/views/widgets/calendar_widgets/week_view.dart';
|
||||||
import 'package:em2rp/views/pages/event_add_page.dart';
|
import 'package:em2rp/views/event_add_page.dart';
|
||||||
import 'package:em2rp/views/widgets/calendar_widgets/mobile_calendar_view.dart';
|
import 'package:em2rp/views/widgets/calendar_widgets/mobile_calendar_view.dart';
|
||||||
import 'package:em2rp/utils/colors.dart';
|
import 'package:em2rp/utils/colors.dart';
|
||||||
|
|
||||||
@ -40,23 +40,43 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
final events = eventProvider.events;
|
final events = eventProvider.events;
|
||||||
if (events.isNotEmpty) {
|
if (events.isNotEmpty) {
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
events.sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
|
// Pour mobile : sélectionner le premier événement du jour ou le prochain événement à venir
|
||||||
int closestIdx = 0;
|
final todayEvents = events
|
||||||
Duration minDiff = (events[0].startDateTime.difference(now)).abs();
|
.where((e) =>
|
||||||
for (int i = 1; i < events.length; i++) {
|
e.startDateTime.year == now.year &&
|
||||||
final diff = (events[i].startDateTime.difference(now)).abs();
|
e.startDateTime.month == now.month &&
|
||||||
if (diff < minDiff) {
|
e.startDateTime.day == now.day)
|
||||||
minDiff = diff;
|
.toList()
|
||||||
closestIdx = i;
|
..sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
|
||||||
|
EventModel? selected;
|
||||||
|
DateTime? selectedDay;
|
||||||
|
int selectedEventIndex = 0;
|
||||||
|
if (todayEvents.isNotEmpty) {
|
||||||
|
selected = todayEvents[0];
|
||||||
|
selectedDay = DateTime(now.year, now.month, now.day);
|
||||||
|
} else {
|
||||||
|
// Chercher le prochain événement à venir
|
||||||
|
final futureEvents = events
|
||||||
|
.where((e) => e.startDateTime.isAfter(now))
|
||||||
|
.toList()
|
||||||
|
..sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
|
||||||
|
if (futureEvents.isNotEmpty) {
|
||||||
|
selected = futureEvents[0];
|
||||||
|
selectedDay = DateTime(selected.startDateTime.year,
|
||||||
|
selected.startDateTime.month, selected.startDateTime.day);
|
||||||
|
} else {
|
||||||
|
// Aucun événement à venir, prendre le plus proche dans le passé
|
||||||
|
events.sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
|
||||||
|
selected = events.last;
|
||||||
|
selectedDay = DateTime(selected.startDateTime.year,
|
||||||
|
selected.startDateTime.month, selected.startDateTime.day);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
final closestEvent = events[closestIdx];
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_selectedDay = DateTime(closestEvent.startDateTime.year,
|
_selectedDay = selectedDay;
|
||||||
closestEvent.startDateTime.month, closestEvent.startDateTime.day);
|
_focusedDay = selectedDay!;
|
||||||
_focusedDay = _selectedDay!;
|
|
||||||
_selectedEventIndex = 0;
|
_selectedEventIndex = 0;
|
||||||
_selectedEvent = closestEvent;
|
_selectedEvent = selected;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -188,111 +208,221 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
? eventsForSelectedDay[_selectedEventIndex]
|
? eventsForSelectedDay[_selectedEventIndex]
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return Stack(
|
// GESTURE DETECTOR pour swipe vertical (plier/déplier) et horizontal (mois)
|
||||||
children: [
|
return GestureDetector(
|
||||||
// Calendrier + détails en dessous
|
onVerticalDragEnd: (details) {
|
||||||
AnimatedPositioned(
|
if (details.primaryVelocity != null) {
|
||||||
duration: const Duration(milliseconds: 400),
|
if (details.primaryVelocity! < -200) {
|
||||||
curve: Curves.easeInOut,
|
// Swipe vers le haut : plier
|
||||||
top: _calendarCollapsed ? -600 : 0, // cache le calendrier en haut
|
setState(() {
|
||||||
left: 0,
|
_calendarCollapsed = true;
|
||||||
right: 0,
|
});
|
||||||
height: _calendarCollapsed ? 0 : null,
|
} else if (details.primaryVelocity! > 200) {
|
||||||
child: Container(
|
// Swipe vers le bas : déplier
|
||||||
height: MediaQuery.of(context).size.height,
|
setState(() {
|
||||||
child: Column(
|
_calendarCollapsed = false;
|
||||||
children: [
|
});
|
||||||
_buildMonthHeader(context),
|
}
|
||||||
if (!_calendarCollapsed)
|
}
|
||||||
MobileCalendarView(
|
},
|
||||||
focusedDay: _focusedDay,
|
onHorizontalDragEnd: (details) {
|
||||||
selectedDay: _selectedDay,
|
if (details.primaryVelocity != null) {
|
||||||
events: eventProvider.events,
|
if (details.primaryVelocity! < -200) {
|
||||||
onDaySelected: (day) {
|
// Swipe gauche : mois suivant
|
||||||
final eventsForDay = eventProvider.events
|
setState(() {
|
||||||
.where((e) =>
|
_focusedDay =
|
||||||
e.startDateTime.year == day.year &&
|
DateTime(_focusedDay.year, _focusedDay.month + 1, 1);
|
||||||
e.startDateTime.month == day.month &&
|
});
|
||||||
e.startDateTime.day == day.day)
|
} else if (details.primaryVelocity! > 200) {
|
||||||
.toList()
|
// Swipe droite : mois précédent
|
||||||
..sort((a, b) =>
|
setState(() {
|
||||||
a.startDateTime.compareTo(b.startDateTime));
|
_focusedDay =
|
||||||
setState(() {
|
DateTime(_focusedDay.year, _focusedDay.month - 1, 1);
|
||||||
_selectedDay = day;
|
});
|
||||||
_calendarCollapsed = false;
|
}
|
||||||
_selectedEventIndex = 0;
|
}
|
||||||
_selectedEvent =
|
},
|
||||||
eventsForDay.isNotEmpty ? eventsForDay[0] : null;
|
child: Stack(
|
||||||
});
|
children: [
|
||||||
},
|
// Calendrier + détails en dessous
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: hasEvents
|
|
||||||
? EventDetails(
|
|
||||||
event: eventsForSelectedDay[_selectedEventIndex],
|
|
||||||
selectedDate: _selectedDay,
|
|
||||||
events: eventsForSelectedDay.cast<EventModel>(),
|
|
||||||
onSelectEvent: (event, date) {
|
|
||||||
final idx = eventsForSelectedDay
|
|
||||||
.indexWhere((e) => e.id == event.id);
|
|
||||||
setState(() {
|
|
||||||
_selectedEventIndex = idx >= 0 ? idx : 0;
|
|
||||||
_selectedEvent = event;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
)
|
|
||||||
: Center(
|
|
||||||
child:
|
|
||||||
Text('Aucun événement ne démarre à cette date')),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// Vue détail (prend tout l'espace quand calendrier caché)
|
|
||||||
if (_calendarCollapsed && _selectedDay != null)
|
|
||||||
AnimatedPositioned(
|
AnimatedPositioned(
|
||||||
duration: const Duration(milliseconds: 400),
|
duration: const Duration(milliseconds: 400),
|
||||||
curve: Curves.easeInOut,
|
curve: Curves.easeInOut,
|
||||||
top: _calendarCollapsed ? 0 : 600,
|
top: _calendarCollapsed ? -600 : 0, // cache le calendrier en haut
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
bottom: 0,
|
height: _calendarCollapsed ? 0 : null,
|
||||||
child: Container(
|
child: Container(
|
||||||
height: MediaQuery.of(context).size.height,
|
height: MediaQuery.of(context).size.height,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
_buildMonthHeader(context),
|
_buildMonthHeader(context),
|
||||||
Expanded(
|
if (!_calendarCollapsed)
|
||||||
child: Stack(
|
// Ajout d'un GestureDetector pour swipe horizontal sur le calendrier
|
||||||
children: [
|
GestureDetector(
|
||||||
if (currentEvent != null)
|
onHorizontalDragEnd: (details) {
|
||||||
EventDetails(
|
if (details.primaryVelocity != null) {
|
||||||
event: currentEvent,
|
if (details.primaryVelocity! < -200) {
|
||||||
selectedDate: _selectedDay,
|
// Swipe gauche : mois suivant
|
||||||
events: eventsForSelectedDay.cast<EventModel>(),
|
setState(() {
|
||||||
onSelectEvent: (event, date) {
|
_focusedDay = DateTime(
|
||||||
final idx = eventsForSelectedDay
|
_focusedDay.year, _focusedDay.month + 1, 1);
|
||||||
.indexWhere((e) => e.id == event.id);
|
});
|
||||||
setState(() {
|
} else if (details.primaryVelocity! > 200) {
|
||||||
_selectedEventIndex = idx >= 0 ? idx : 0;
|
// Swipe droite : mois précédent
|
||||||
_selectedEvent = event;
|
setState(() {
|
||||||
});
|
_focusedDay = DateTime(
|
||||||
},
|
_focusedDay.year, _focusedDay.month - 1, 1);
|
||||||
),
|
});
|
||||||
if (!hasEvents)
|
}
|
||||||
Center(
|
}
|
||||||
child:
|
},
|
||||||
Text('Aucun événement ne démarre à cette date'),
|
child: MobileCalendarView(
|
||||||
),
|
focusedDay: _focusedDay,
|
||||||
],
|
selectedDay: _selectedDay,
|
||||||
|
events: eventProvider.events,
|
||||||
|
onDaySelected: (day) {
|
||||||
|
final eventsForDay = eventProvider.events
|
||||||
|
.where((e) =>
|
||||||
|
e.startDateTime.year == day.year &&
|
||||||
|
e.startDateTime.month == day.month &&
|
||||||
|
e.startDateTime.day == day.day)
|
||||||
|
.toList()
|
||||||
|
..sort((a, b) =>
|
||||||
|
a.startDateTime.compareTo(b.startDateTime));
|
||||||
|
setState(() {
|
||||||
|
_selectedDay = day;
|
||||||
|
_calendarCollapsed = false;
|
||||||
|
_selectedEventIndex = 0;
|
||||||
|
_selectedEvent = eventsForDay.isNotEmpty
|
||||||
|
? eventsForDay[0]
|
||||||
|
: null;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
Expanded(
|
||||||
|
child: hasEvents
|
||||||
|
// Ajout d'un GestureDetector pour swipe horizontal sur le détail événement
|
||||||
|
? GestureDetector(
|
||||||
|
onHorizontalDragEnd: (details) {
|
||||||
|
if (details.primaryVelocity != null) {
|
||||||
|
if (details.primaryVelocity! < -200) {
|
||||||
|
// Swipe gauche : événement suivant
|
||||||
|
if (_selectedEventIndex <
|
||||||
|
eventsForSelectedDay.length - 1) {
|
||||||
|
setState(() {
|
||||||
|
_selectedEventIndex++;
|
||||||
|
_selectedEvent = eventsForSelectedDay[
|
||||||
|
_selectedEventIndex];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (details.primaryVelocity! > 200) {
|
||||||
|
// Swipe droite : événement précédent
|
||||||
|
if (_selectedEventIndex > 0) {
|
||||||
|
setState(() {
|
||||||
|
_selectedEventIndex--;
|
||||||
|
_selectedEvent = eventsForSelectedDay[
|
||||||
|
_selectedEventIndex];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: EventDetails(
|
||||||
|
event: eventsForSelectedDay[_selectedEventIndex],
|
||||||
|
selectedDate: _selectedDay,
|
||||||
|
events: eventsForSelectedDay.cast<EventModel>(),
|
||||||
|
onSelectEvent: (event, date) {
|
||||||
|
final idx = eventsForSelectedDay
|
||||||
|
.indexWhere((e) => e.id == event.id);
|
||||||
|
setState(() {
|
||||||
|
_selectedEventIndex = idx >= 0 ? idx : 0;
|
||||||
|
_selectedEvent = event;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Center(
|
||||||
|
child: Text(
|
||||||
|
'Aucun événement ne démarre à cette date')),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
// Vue détail (prend tout l'espace quand calendrier caché)
|
||||||
|
if (_calendarCollapsed && _selectedDay != null)
|
||||||
|
AnimatedPositioned(
|
||||||
|
duration: const Duration(milliseconds: 400),
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
top: _calendarCollapsed ? 0 : 600,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
child: Container(
|
||||||
|
height: MediaQuery.of(context).size.height,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
_buildMonthHeader(context),
|
||||||
|
Expanded(
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
if (currentEvent != null)
|
||||||
|
// Ajout d'un GestureDetector pour swipe horizontal sur le détail événement
|
||||||
|
GestureDetector(
|
||||||
|
onHorizontalDragEnd: (details) {
|
||||||
|
if (details.primaryVelocity != null) {
|
||||||
|
if (details.primaryVelocity! < -200) {
|
||||||
|
// Swipe gauche : événement suivant
|
||||||
|
if (_selectedEventIndex <
|
||||||
|
eventsForSelectedDay.length - 1) {
|
||||||
|
setState(() {
|
||||||
|
_selectedEventIndex++;
|
||||||
|
_selectedEvent = eventsForSelectedDay[
|
||||||
|
_selectedEventIndex];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (details.primaryVelocity! > 200) {
|
||||||
|
// Swipe droite : événement précédent
|
||||||
|
if (_selectedEventIndex > 0) {
|
||||||
|
setState(() {
|
||||||
|
_selectedEventIndex--;
|
||||||
|
_selectedEvent = eventsForSelectedDay[
|
||||||
|
_selectedEventIndex];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: EventDetails(
|
||||||
|
event: currentEvent,
|
||||||
|
selectedDate: _selectedDay,
|
||||||
|
events: eventsForSelectedDay.cast<EventModel>(),
|
||||||
|
onSelectEvent: (event, date) {
|
||||||
|
final idx = eventsForSelectedDay
|
||||||
|
.indexWhere((e) => e.id == event.id);
|
||||||
|
setState(() {
|
||||||
|
_selectedEventIndex = idx >= 0 ? idx : 0;
|
||||||
|
_selectedEvent = event;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!hasEvents)
|
||||||
|
Center(
|
||||||
|
child: Text(
|
||||||
|
'Aucun événement ne démarre à cette date'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
788
em2rp/lib/views/event_add_page.dart
Normal file
788
em2rp/lib/views/event_add_page.dart
Normal file
@ -0,0 +1,788 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:em2rp/providers/event_provider.dart';
|
||||||
|
import 'package:em2rp/models/event_model.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
|
import 'package:em2rp/views/widgets/inputs/int_stepper_field.dart';
|
||||||
|
import 'package:em2rp/models/user_model.dart';
|
||||||
|
import 'package:em2rp/views/widgets/image/profile_picture.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
|
import 'package:firebase_storage/firebase_storage.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:firebase_auth/firebase_auth.dart';
|
||||||
|
import 'package:em2rp/providers/local_user_provider.dart';
|
||||||
|
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';
|
||||||
|
// ignore: avoid_web_libraries_in_flutter
|
||||||
|
import 'dart:html' as html;
|
||||||
|
|
||||||
|
class EventAddPage extends StatefulWidget {
|
||||||
|
const EventAddPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<EventAddPage> createState() => _EventAddPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EventAddPageState extends State<EventAddPage> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
final TextEditingController _nameController = TextEditingController();
|
||||||
|
final TextEditingController _descriptionController = TextEditingController();
|
||||||
|
final TextEditingController _basePriceController = TextEditingController();
|
||||||
|
final TextEditingController _installationController = TextEditingController();
|
||||||
|
final TextEditingController _disassemblyController = TextEditingController();
|
||||||
|
final TextEditingController _addressController = TextEditingController();
|
||||||
|
DateTime? _startDateTime;
|
||||||
|
DateTime? _endDateTime;
|
||||||
|
bool _isLoading = false;
|
||||||
|
String? _error;
|
||||||
|
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 = [];
|
||||||
|
bool _isLoadingUsers = true;
|
||||||
|
List<Map<String, String>> _uploadedFiles = [];
|
||||||
|
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() {
|
||||||
|
final lines = '\n'.allMatches(_descriptionController.text).length + 1;
|
||||||
|
setState(() {
|
||||||
|
_descriptionMaxLines = lines.clamp(3, 6);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onAnyFieldChanged() {
|
||||||
|
if (!_formChanged) {
|
||||||
|
setState(() {
|
||||||
|
_formChanged = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _fetchUsers() async {
|
||||||
|
final snapshot = await FirebaseFirestore.instance.collection('users').get();
|
||||||
|
setState(() {
|
||||||
|
_allUsers = snapshot.docs
|
||||||
|
.map((doc) => UserModel.fromMap(doc.data(), doc.id))
|
||||||
|
.toList();
|
||||||
|
_isLoadingUsers = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onEventTypeChanged(String? newType) {
|
||||||
|
if (newType == _selectedEventType) return;
|
||||||
|
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) {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
_onAnyFieldChanged();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_nameController.dispose();
|
||||||
|
_descriptionController.dispose();
|
||||||
|
_basePriceController.dispose();
|
||||||
|
_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);
|
||||||
|
if (result != null && result.files.isNotEmpty) {
|
||||||
|
setState(() => _isLoading = true);
|
||||||
|
try {
|
||||||
|
List<Map<String, String>> files = [];
|
||||||
|
for (final file in result.files) {
|
||||||
|
final fileBytes = file.bytes;
|
||||||
|
final fileName = file.name;
|
||||||
|
if (fileBytes != null) {
|
||||||
|
final ref = FirebaseStorage.instance.ref().child(
|
||||||
|
'events/temp/${DateTime.now().millisecondsSinceEpoch}_$fileName');
|
||||||
|
final uploadTask = await ref.putData(fileBytes);
|
||||||
|
final url = await uploadTask.ref.getDownloadURL();
|
||||||
|
files.add({'name': fileName, 'url': url});
|
||||||
|
} else {
|
||||||
|
setState(() {
|
||||||
|
_error = "Impossible de lire le fichier ${file.name}";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_uploadedFiles.addAll(files);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
setState(() {
|
||||||
|
_error = 'Erreur lors de l\'upload : $e';
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setState(() => _isLoading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> moveEventFileHttp({
|
||||||
|
required String sourcePath,
|
||||||
|
required String destinationPath,
|
||||||
|
}) async {
|
||||||
|
final url = Uri.parse(
|
||||||
|
'https://us-central1-em2rp-951dc.cloudfunctions.net/moveEventFileV2');
|
||||||
|
final user = FirebaseAuth.instance.currentUser;
|
||||||
|
final idToken = await user?.getIdToken();
|
||||||
|
final response = await http.post(
|
||||||
|
url,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
if (idToken != null) 'Authorization': 'Bearer $idToken',
|
||||||
|
},
|
||||||
|
body: jsonEncode({
|
||||||
|
'data': {
|
||||||
|
'sourcePath': sourcePath,
|
||||||
|
'destinationPath': destinationPath,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final data = jsonDecode(response.body);
|
||||||
|
if (data['url'] != null) {
|
||||||
|
return data['url'] as String;
|
||||||
|
} else if (data['result'] != null && data['result']['url'] != null) {
|
||||||
|
return data['result']['url'] as String;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} else {
|
||||||
|
print('Erreur Cloud Function: \\n${response.body}');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
if (!_formKey.currentState!.validate() ||
|
||||||
|
_startDateTime == null ||
|
||||||
|
_endDateTime == null ||
|
||||||
|
_selectedEventType == null ||
|
||||||
|
_addressController.text.isEmpty) return;
|
||||||
|
if (_endDateTime!.isBefore(_startDateTime!) ||
|
||||||
|
_endDateTime!.isAtSameMomentAs(_startDateTime!)) {
|
||||||
|
setState(() {
|
||||||
|
_error = "La date de fin doit être postérieure à la date de début.";
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_error = null;
|
||||||
|
_success = null;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
final eventProvider = Provider.of<EventProvider>(context, listen: false);
|
||||||
|
final newEvent = EventModel(
|
||||||
|
id: '',
|
||||||
|
name: _nameController.text.trim(),
|
||||||
|
description: _descriptionController.text.trim(),
|
||||||
|
startDateTime: _startDateTime!,
|
||||||
|
endDateTime: _endDateTime!,
|
||||||
|
basePrice: double.tryParse(_basePriceController.text) ?? 0.0,
|
||||||
|
installationTime: int.tryParse(_installationController.text) ?? 0,
|
||||||
|
disassemblyTime: int.tryParse(_disassemblyController.text) ?? 0,
|
||||||
|
eventTypeId: _selectedEventType!,
|
||||||
|
customerId: '',
|
||||||
|
address: _addressController.text.trim(),
|
||||||
|
workforce: _selectedUserIds
|
||||||
|
.map((id) => FirebaseFirestore.instance.collection('users').doc(id))
|
||||||
|
.toList(),
|
||||||
|
latitude: 0.0,
|
||||||
|
longitude: 0.0,
|
||||||
|
documents: _uploadedFiles,
|
||||||
|
options: _selectedOptions
|
||||||
|
.map((opt) => {
|
||||||
|
'name': opt['name'],
|
||||||
|
'price': opt['price'],
|
||||||
|
})
|
||||||
|
.toList(),
|
||||||
|
status: _selectedStatus,
|
||||||
|
);
|
||||||
|
final docRef = await FirebaseFirestore.instance
|
||||||
|
.collection('events')
|
||||||
|
.add(newEvent.toMap());
|
||||||
|
final eventId = docRef.id;
|
||||||
|
List<Map<String, String>> newFiles = [];
|
||||||
|
for (final file in _uploadedFiles) {
|
||||||
|
final fileName = file['name']!;
|
||||||
|
final oldUrl = file['url']!;
|
||||||
|
String sourcePath;
|
||||||
|
final tempPattern = RegExp(r'events/temp/[^?]+');
|
||||||
|
final match = tempPattern.firstMatch(oldUrl);
|
||||||
|
if (match != null) {
|
||||||
|
sourcePath = match.group(0)!;
|
||||||
|
} else {
|
||||||
|
final tempFileName =
|
||||||
|
Uri.decodeComponent(oldUrl.split('/').last.split('?').first);
|
||||||
|
sourcePath = tempFileName;
|
||||||
|
}
|
||||||
|
final destinationPath = 'events/$eventId/$fileName';
|
||||||
|
final newUrl = await moveEventFileHttp(
|
||||||
|
sourcePath: sourcePath,
|
||||||
|
destinationPath: destinationPath,
|
||||||
|
);
|
||||||
|
if (newUrl != null) {
|
||||||
|
newFiles.add({'name': fileName, 'url': newUrl});
|
||||||
|
} else {
|
||||||
|
newFiles.add({'name': fileName, 'url': oldUrl});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await docRef.update({'documents': newFiles});
|
||||||
|
final localUserProvider =
|
||||||
|
Provider.of<LocalUserProvider>(context, listen: false);
|
||||||
|
final userId = localUserProvider.uid;
|
||||||
|
final canViewAllEvents =
|
||||||
|
localUserProvider.hasPermission('view_all_events');
|
||||||
|
if (userId != null) {
|
||||||
|
await eventProvider.loadUserEvents(userId,
|
||||||
|
canViewAllEvents: canViewAllEvents);
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_success = "Événement créé avec succès !";
|
||||||
|
});
|
||||||
|
if (context.mounted) Navigator.of(context).pop();
|
||||||
|
} catch (e) {
|
||||||
|
setState(() {
|
||||||
|
_error = "Erreur lors de la création : $e";
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSectionTitle(String title) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 16.0, bottom: 8.0),
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final isMobile = MediaQuery.of(context).size.width < 600;
|
||||||
|
return WillPopScope(
|
||||||
|
onWillPop: _onWillPop,
|
||||||
|
child: Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Créer un événement'),
|
||||||
|
),
|
||||||
|
body: Center(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: (isMobile
|
||||||
|
? Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16, vertical: 12),
|
||||||
|
child: Container(
|
||||||
|
// Pas de Card sur mobile, juste un conteneur
|
||||||
|
child: _buildFormContent(isMobile),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: 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: _buildFormContent(isMobile),
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildFormContent(bool isMobile) {
|
||||||
|
return 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),
|
||||||
|
),
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
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(
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
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',
|
||||||
|
),
|
||||||
|
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,
|
||||||
|
isMobile: isMobile,
|
||||||
|
),
|
||||||
|
_buildSectionTitle('Détails'),
|
||||||
|
AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
minHeight: 48,
|
||||||
|
maxHeight: isMobile ? 48.0 * 20 : 48.0 * 10,
|
||||||
|
),
|
||||||
|
child: TextFormField(
|
||||||
|
controller: _descriptionController,
|
||||||
|
minLines: 1,
|
||||||
|
maxLines: _descriptionMaxLines > (isMobile ? 20 : 10)
|
||||||
|
? (isMobile ? 20 : 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'),
|
||||||
|
if (isMobile)
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
ElevatedButton.icon(
|
||||||
|
icon: const Icon(Icons.attach_file),
|
||||||
|
label: const Text('Ajouter un fichier'),
|
||||||
|
onPressed: _isLoading ? null : _pickAndUploadFiles,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
..._uploadedFiles.map((file) => ListTile(
|
||||||
|
dense: true,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
leading: const Icon(Icons.insert_drive_file),
|
||||||
|
title: Text(file['name'] ?? ''),
|
||||||
|
trailing: IconButton(
|
||||||
|
icon: const Icon(Icons.delete),
|
||||||
|
onPressed: _isLoading
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
setState(() {
|
||||||
|
_uploadedFiles.remove(file);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
if (_error != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4.0),
|
||||||
|
child: Text(_error!,
|
||||||
|
style: const TextStyle(color: Colors.red)),
|
||||||
|
),
|
||||||
|
if (_success != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4.0),
|
||||||
|
child: Text(_success!,
|
||||||
|
style: const TextStyle(color: Colors.green)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (!isMobile)
|
||||||
|
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 shouldLeave = await _onWillPop();
|
||||||
|
if (shouldLeave && context.mounted) {
|
||||||
|
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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -1,751 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:provider/provider.dart';
|
|
||||||
import 'package:em2rp/providers/event_provider.dart';
|
|
||||||
import 'package:em2rp/models/event_model.dart';
|
|
||||||
import 'package:intl/intl.dart';
|
|
||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
|
||||||
import 'package:em2rp/views/widgets/inputs/int_stepper_field.dart';
|
|
||||||
import 'package:em2rp/models/user_model.dart';
|
|
||||||
import 'package:em2rp/views/widgets/image/profile_picture.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:file_picker/file_picker.dart';
|
|
||||||
import 'package:firebase_storage/firebase_storage.dart';
|
|
||||||
import 'package:path/path.dart' as p;
|
|
||||||
import 'dart:convert';
|
|
||||||
import 'package:http/http.dart' as http;
|
|
||||||
import 'package:firebase_auth/firebase_auth.dart';
|
|
||||||
import 'package:em2rp/providers/local_user_provider.dart';
|
|
||||||
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';
|
|
||||||
// ignore: avoid_web_libraries_in_flutter
|
|
||||||
import 'dart:html' as html;
|
|
||||||
|
|
||||||
class EventAddPage extends StatefulWidget {
|
|
||||||
const EventAddPage({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<EventAddPage> createState() => _EventAddPageState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _EventAddPageState extends State<EventAddPage> {
|
|
||||||
final _formKey = GlobalKey<FormState>();
|
|
||||||
final TextEditingController _nameController = TextEditingController();
|
|
||||||
final TextEditingController _descriptionController = TextEditingController();
|
|
||||||
final TextEditingController _basePriceController = TextEditingController();
|
|
||||||
final TextEditingController _installationController = TextEditingController();
|
|
||||||
final TextEditingController _disassemblyController = TextEditingController();
|
|
||||||
final TextEditingController _addressController = TextEditingController();
|
|
||||||
DateTime? _startDateTime;
|
|
||||||
DateTime? _endDateTime;
|
|
||||||
bool _isLoading = false;
|
|
||||||
String? _error;
|
|
||||||
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 = [];
|
|
||||||
bool _isLoadingUsers = true;
|
|
||||||
List<Map<String, String>> _uploadedFiles = [];
|
|
||||||
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() {
|
|
||||||
final lines = '\n'.allMatches(_descriptionController.text).length + 1;
|
|
||||||
setState(() {
|
|
||||||
_descriptionMaxLines = lines.clamp(3, 6);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onAnyFieldChanged() {
|
|
||||||
if (!_formChanged) {
|
|
||||||
setState(() {
|
|
||||||
_formChanged = true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _fetchUsers() async {
|
|
||||||
final snapshot = await FirebaseFirestore.instance.collection('users').get();
|
|
||||||
setState(() {
|
|
||||||
_allUsers = snapshot.docs
|
|
||||||
.map((doc) => UserModel.fromMap(doc.data(), doc.id))
|
|
||||||
.toList();
|
|
||||||
_isLoadingUsers = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onEventTypeChanged(String? newType) {
|
|
||||||
if (newType == _selectedEventType) return;
|
|
||||||
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) {
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
_onAnyFieldChanged();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_nameController.dispose();
|
|
||||||
_descriptionController.dispose();
|
|
||||||
_basePriceController.dispose();
|
|
||||||
_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);
|
|
||||||
if (result != null && result.files.isNotEmpty) {
|
|
||||||
setState(() => _isLoading = true);
|
|
||||||
try {
|
|
||||||
List<Map<String, String>> files = [];
|
|
||||||
for (final file in result.files) {
|
|
||||||
final fileBytes = file.bytes;
|
|
||||||
final fileName = file.name;
|
|
||||||
if (fileBytes != null) {
|
|
||||||
final ref = FirebaseStorage.instance.ref().child(
|
|
||||||
'events/temp/${DateTime.now().millisecondsSinceEpoch}_$fileName');
|
|
||||||
final uploadTask = await ref.putData(fileBytes);
|
|
||||||
final url = await uploadTask.ref.getDownloadURL();
|
|
||||||
files.add({'name': fileName, 'url': url});
|
|
||||||
} else {
|
|
||||||
setState(() {
|
|
||||||
_error = "Impossible de lire le fichier ${file.name}";
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setState(() {
|
|
||||||
_uploadedFiles.addAll(files);
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
setState(() {
|
|
||||||
_error = 'Erreur lors de l\'upload : $e';
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setState(() => _isLoading = false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<String?> moveEventFileHttp({
|
|
||||||
required String sourcePath,
|
|
||||||
required String destinationPath,
|
|
||||||
}) async {
|
|
||||||
final url = Uri.parse(
|
|
||||||
'https://us-central1-em2rp-951dc.cloudfunctions.net/moveEventFileV2');
|
|
||||||
final user = FirebaseAuth.instance.currentUser;
|
|
||||||
final idToken = await user?.getIdToken();
|
|
||||||
final response = await http.post(
|
|
||||||
url,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
if (idToken != null) 'Authorization': 'Bearer $idToken',
|
|
||||||
},
|
|
||||||
body: jsonEncode({
|
|
||||||
'data': {
|
|
||||||
'sourcePath': sourcePath,
|
|
||||||
'destinationPath': destinationPath,
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
if (response.statusCode == 200) {
|
|
||||||
final data = jsonDecode(response.body);
|
|
||||||
if (data['url'] != null) {
|
|
||||||
return data['url'] as String;
|
|
||||||
} else if (data['result'] != null && data['result']['url'] != null) {
|
|
||||||
return data['result']['url'] as String;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
} else {
|
|
||||||
print('Erreur Cloud Function: \\n${response.body}');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _submit() async {
|
|
||||||
if (!_formKey.currentState!.validate() ||
|
|
||||||
_startDateTime == null ||
|
|
||||||
_endDateTime == null ||
|
|
||||||
_selectedEventType == null ||
|
|
||||||
_addressController.text.isEmpty) return;
|
|
||||||
if (_endDateTime!.isBefore(_startDateTime!) ||
|
|
||||||
_endDateTime!.isAtSameMomentAs(_startDateTime!)) {
|
|
||||||
setState(() {
|
|
||||||
_error = "La date de fin doit être postérieure à la date de début.";
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setState(() {
|
|
||||||
_isLoading = true;
|
|
||||||
_error = null;
|
|
||||||
_success = null;
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
final eventProvider = Provider.of<EventProvider>(context, listen: false);
|
|
||||||
final newEvent = EventModel(
|
|
||||||
id: '',
|
|
||||||
name: _nameController.text.trim(),
|
|
||||||
description: _descriptionController.text.trim(),
|
|
||||||
startDateTime: _startDateTime!,
|
|
||||||
endDateTime: _endDateTime!,
|
|
||||||
basePrice: double.tryParse(_basePriceController.text) ?? 0.0,
|
|
||||||
installationTime: int.tryParse(_installationController.text) ?? 0,
|
|
||||||
disassemblyTime: int.tryParse(_disassemblyController.text) ?? 0,
|
|
||||||
eventTypeId: _selectedEventType!,
|
|
||||||
customerId: '',
|
|
||||||
address: _addressController.text.trim(),
|
|
||||||
workforce: _selectedUserIds
|
|
||||||
.map((id) => FirebaseFirestore.instance.collection('users').doc(id))
|
|
||||||
.toList(),
|
|
||||||
latitude: 0.0,
|
|
||||||
longitude: 0.0,
|
|
||||||
documents: _uploadedFiles,
|
|
||||||
options: _selectedOptions
|
|
||||||
.map((opt) => {
|
|
||||||
'name': opt['name'],
|
|
||||||
'price': opt['price'],
|
|
||||||
})
|
|
||||||
.toList(),
|
|
||||||
status: _selectedStatus,
|
|
||||||
);
|
|
||||||
final docRef = await FirebaseFirestore.instance
|
|
||||||
.collection('events')
|
|
||||||
.add(newEvent.toMap());
|
|
||||||
final eventId = docRef.id;
|
|
||||||
List<Map<String, String>> newFiles = [];
|
|
||||||
for (final file in _uploadedFiles) {
|
|
||||||
final fileName = file['name']!;
|
|
||||||
final oldUrl = file['url']!;
|
|
||||||
String sourcePath;
|
|
||||||
final tempPattern = RegExp(r'events/temp/[^?]+');
|
|
||||||
final match = tempPattern.firstMatch(oldUrl);
|
|
||||||
if (match != null) {
|
|
||||||
sourcePath = match.group(0)!;
|
|
||||||
} else {
|
|
||||||
final tempFileName =
|
|
||||||
Uri.decodeComponent(oldUrl.split('/').last.split('?').first);
|
|
||||||
sourcePath = tempFileName;
|
|
||||||
}
|
|
||||||
final destinationPath = 'events/$eventId/$fileName';
|
|
||||||
final newUrl = await moveEventFileHttp(
|
|
||||||
sourcePath: sourcePath,
|
|
||||||
destinationPath: destinationPath,
|
|
||||||
);
|
|
||||||
if (newUrl != null) {
|
|
||||||
newFiles.add({'name': fileName, 'url': newUrl});
|
|
||||||
} else {
|
|
||||||
newFiles.add({'name': fileName, 'url': oldUrl});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await docRef.update({'documents': newFiles});
|
|
||||||
final localUserProvider =
|
|
||||||
Provider.of<LocalUserProvider>(context, listen: false);
|
|
||||||
final userId = localUserProvider.uid;
|
|
||||||
final canViewAllEvents =
|
|
||||||
localUserProvider.hasPermission('view_all_events');
|
|
||||||
if (userId != null) {
|
|
||||||
await eventProvider.loadUserEvents(userId,
|
|
||||||
canViewAllEvents: canViewAllEvents);
|
|
||||||
}
|
|
||||||
setState(() {
|
|
||||||
_success = "Événement créé avec succès !";
|
|
||||||
});
|
|
||||||
if (context.mounted) Navigator.of(context).pop();
|
|
||||||
} catch (e) {
|
|
||||||
setState(() {
|
|
||||||
_error = "Erreur lors de la création : $e";
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setState(() {
|
|
||||||
_isLoading = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildSectionTitle(String title) {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 16.0, bottom: 8.0),
|
|
||||||
child: Align(
|
|
||||||
alignment: Alignment.centerLeft,
|
|
||||||
child: Text(
|
|
||||||
title,
|
|
||||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
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),
|
|
||||||
),
|
|
||||||
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),
|
|
||||||
),
|
|
||||||
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(
|
|
||||||
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),
|
|
||||||
),
|
|
||||||
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',
|
|
||||||
),
|
|
||||||
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 shouldLeave = await _onWillPop();
|
|
||||||
if (shouldLeave && context.mounted) {
|
|
||||||
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'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
@ -8,6 +8,7 @@ class OptionSelectorWidget extends StatefulWidget {
|
|||||||
final ValueChanged<List<Map<String, dynamic>>> onChanged;
|
final ValueChanged<List<Map<String, dynamic>>> onChanged;
|
||||||
final void Function(String name)? onRemove;
|
final void Function(String name)? onRemove;
|
||||||
final bool eventTypeRequired;
|
final bool eventTypeRequired;
|
||||||
|
final bool isMobile;
|
||||||
|
|
||||||
const OptionSelectorWidget({
|
const OptionSelectorWidget({
|
||||||
super.key,
|
super.key,
|
||||||
@ -16,6 +17,7 @@ class OptionSelectorWidget extends StatefulWidget {
|
|||||||
required this.onChanged,
|
required this.onChanged,
|
||||||
this.onRemove,
|
this.onRemove,
|
||||||
this.eventTypeRequired = false,
|
this.eventTypeRequired = false,
|
||||||
|
this.isMobile = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -79,53 +81,56 @@ class _OptionSelectorWidgetState extends State<OptionSelectorWidget> {
|
|||||||
Text('Options sélectionnées',
|
Text('Options sélectionnées',
|
||||||
style: Theme.of(context).textTheme.titleMedium),
|
style: Theme.of(context).textTheme.titleMedium),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Wrap(
|
Column(
|
||||||
spacing: 12,
|
|
||||||
runSpacing: 12,
|
|
||||||
children: widget.selectedOptions
|
children: widget.selectedOptions
|
||||||
.map((opt) => SizedBox(
|
.map((opt) => Card(
|
||||||
width: 260,
|
elevation: widget.isMobile ? 0 : 2,
|
||||||
child: Card(
|
margin: EdgeInsets.symmetric(
|
||||||
elevation: 2,
|
vertical: widget.isMobile ? 4 : 8,
|
||||||
child: Padding(
|
horizontal: widget.isMobile ? 0 : 8),
|
||||||
padding: const EdgeInsets.all(12.0),
|
shape: RoundedRectangleBorder(
|
||||||
child: Row(
|
borderRadius:
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
BorderRadius.circular(widget.isMobile ? 8 : 12)),
|
||||||
children: [
|
child: Padding(
|
||||||
Expanded(
|
padding: EdgeInsets.all(widget.isMobile ? 8.0 : 12.0),
|
||||||
child: Column(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(opt['name'] ?? '',
|
Expanded(
|
||||||
style: const TextStyle(
|
child: Column(
|
||||||
fontWeight: FontWeight.bold)),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
const SizedBox(height: 4),
|
children: [
|
||||||
Text(opt['details'] ?? '',
|
Text(opt['name'] ?? '',
|
||||||
style: const TextStyle(fontSize: 13)),
|
style: const TextStyle(
|
||||||
const SizedBox(height: 4),
|
fontWeight: FontWeight.bold)),
|
||||||
Text('Prix : ${opt['price'] ?? ''} €',
|
if (opt['details'] != null &&
|
||||||
style: const TextStyle(fontSize: 13)),
|
opt['details'] != '')
|
||||||
],
|
Padding(
|
||||||
),
|
padding: const EdgeInsets.only(top: 2.0),
|
||||||
|
child: Text(opt['details'],
|
||||||
|
style: const TextStyle(fontSize: 13)),
|
||||||
|
),
|
||||||
|
Text('Prix : ${opt['price'] ?? ''} €',
|
||||||
|
style: const TextStyle(fontSize: 13)),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
IconButton(
|
),
|
||||||
icon: const Icon(Icons.delete),
|
IconButton(
|
||||||
tooltip: 'Supprimer cette option',
|
icon: const Icon(Icons.delete),
|
||||||
onPressed: () {
|
tooltip: 'Supprimer cette option',
|
||||||
if (widget.onRemove != null) {
|
onPressed: () {
|
||||||
widget.onRemove!(opt['name'] as String);
|
if (widget.onRemove != null) {
|
||||||
} else {
|
widget.onRemove!(opt['name'] as String);
|
||||||
final newList =
|
} else {
|
||||||
List<Map<String, dynamic>>.from(
|
final newList = List<Map<String, dynamic>>.from(
|
||||||
widget.selectedOptions)
|
widget.selectedOptions)
|
||||||
..removeWhere(
|
..removeWhere(
|
||||||
(o) => o['name'] == opt['name']);
|
(o) => o['name'] == opt['name']);
|
||||||
widget.onChanged(newList);
|
widget.onChanged(newList);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
))
|
))
|
||||||
|
Reference in New Issue
Block a user