Compare commits
6 Commits
b80a6d2623
...
main
Author | SHA1 | Date | |
---|---|---|---|
080fb7d077 | |||
57c59c911a | |||
acab16e101 | |||
9a9c932262 | |||
004d442e67 | |||
77d0d5cc81 |
@ -13,7 +13,7 @@ import 'views/user_management_page.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'providers/local_user_provider.dart';
|
||||
import 'services/user_service.dart';
|
||||
import 'pages/auth/reset_password_page.dart';
|
||||
import 'views/reset_password_page.dart';
|
||||
import 'config/env.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
|
||||
|
@ -20,21 +20,24 @@ class EventProvider with ChangeNotifier {
|
||||
print(
|
||||
'Loading events for user: $userId (canViewAllEvents: $canViewAllEvents)');
|
||||
QuerySnapshot eventsSnapshot;
|
||||
if (canViewAllEvents) {
|
||||
eventsSnapshot = await _firestore.collection('events').get();
|
||||
} else {
|
||||
eventsSnapshot = await _firestore
|
||||
.collection('events')
|
||||
.where('workforce', arrayContains: userId)
|
||||
.get();
|
||||
}
|
||||
// On charge tous les events pour les users non-admins aussi
|
||||
eventsSnapshot = await _firestore.collection('events').get();
|
||||
|
||||
print('Found ${eventsSnapshot.docs.length} events for user');
|
||||
|
||||
_events = eventsSnapshot.docs.map((doc) {
|
||||
// On filtre côté client si l'utilisateur n'est pas admin
|
||||
final allEvents = eventsSnapshot.docs.map((doc) {
|
||||
print('Event data: ${doc.data()}');
|
||||
return EventModel.fromMap(doc.data() as Map<String, dynamic>, doc.id);
|
||||
}).toList();
|
||||
if (canViewAllEvents) {
|
||||
_events = allEvents;
|
||||
} else {
|
||||
final userRef = _firestore.collection('users').doc(userId);
|
||||
_events = allEvents
|
||||
.where((e) => e.workforce.any((ref) => ref.id == userRef.id))
|
||||
.toList();
|
||||
}
|
||||
|
||||
print('Parsed ${_events.length} events');
|
||||
|
||||
|
@ -10,7 +10,9 @@ import 'package:em2rp/views/widgets/calendar_widgets/event_details.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/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/utils/colors.dart';
|
||||
|
||||
class CalendarPage extends StatefulWidget {
|
||||
const CalendarPage({super.key});
|
||||
@ -24,12 +26,60 @@ class _CalendarPageState extends State<CalendarPage> {
|
||||
DateTime _focusedDay = DateTime.now();
|
||||
DateTime? _selectedDay;
|
||||
EventModel? _selectedEvent;
|
||||
bool _calendarCollapsed = false;
|
||||
int _selectedEventIndex = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
initializeDateFormatting('fr_FR', null);
|
||||
Future.microtask(() => _loadEvents());
|
||||
// Sélection automatique de l'événement le plus proche de maintenant
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final eventProvider = Provider.of<EventProvider>(context, listen: false);
|
||||
final events = eventProvider.events;
|
||||
if (events.isNotEmpty) {
|
||||
final now = DateTime.now();
|
||||
// Pour mobile : sélectionner le premier événement du jour ou le prochain événement à venir
|
||||
final todayEvents = events
|
||||
.where((e) =>
|
||||
e.startDateTime.year == now.year &&
|
||||
e.startDateTime.month == now.month &&
|
||||
e.startDateTime.day == now.day)
|
||||
.toList()
|
||||
..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);
|
||||
}
|
||||
}
|
||||
setState(() {
|
||||
_selectedDay = selectedDay;
|
||||
_focusedDay = selectedDay!;
|
||||
_selectedEventIndex = 0;
|
||||
_selectedEvent = selected;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadEvents() async {
|
||||
@ -69,8 +119,23 @@ class _CalendarPageState extends State<CalendarPage> {
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: const CustomAppBar(
|
||||
title: 'Calendrier',
|
||||
appBar: CustomAppBar(
|
||||
title: _getMonthName(_focusedDay.month),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
_calendarCollapsed
|
||||
? Icons.keyboard_arrow_down
|
||||
: Icons.keyboard_arrow_up,
|
||||
color: AppColors.blanc,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_calendarCollapsed = !_calendarCollapsed;
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
drawer: const MainDrawer(currentPage: '/calendar'),
|
||||
body: isMobile ? _buildMobileLayout() : _buildDesktopLayout(),
|
||||
@ -80,7 +145,7 @@ class _CalendarPageState extends State<CalendarPage> {
|
||||
onPressed: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const EventAddPage(),
|
||||
builder: (context) => const EventAddEditPage(),
|
||||
),
|
||||
);
|
||||
},
|
||||
@ -128,37 +193,331 @@ class _CalendarPageState extends State<CalendarPage> {
|
||||
|
||||
Widget _buildMobileLayout() {
|
||||
final eventProvider = Provider.of<EventProvider>(context);
|
||||
return Column(
|
||||
children: [
|
||||
// Calendrier
|
||||
Expanded(
|
||||
child: _buildCalendar(),
|
||||
),
|
||||
// Détails de l'événement
|
||||
if (_selectedEvent != null)
|
||||
final eventsForSelectedDay = _selectedDay == null
|
||||
? []
|
||||
: eventProvider.events
|
||||
.where((e) =>
|
||||
e.startDateTime.year == _selectedDay!.year &&
|
||||
e.startDateTime.month == _selectedDay!.month &&
|
||||
e.startDateTime.day == _selectedDay!.day)
|
||||
.toList()
|
||||
..sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
|
||||
final hasEvents = eventsForSelectedDay.isNotEmpty;
|
||||
final currentEvent =
|
||||
hasEvents && _selectedEventIndex < eventsForSelectedDay.length
|
||||
? eventsForSelectedDay[_selectedEventIndex]
|
||||
: null;
|
||||
|
||||
// GESTURE DETECTOR pour swipe vertical (plier/déplier) et horizontal (mois)
|
||||
return GestureDetector(
|
||||
onVerticalDragEnd: (details) {
|
||||
if (details.primaryVelocity != null) {
|
||||
if (details.primaryVelocity! < -200) {
|
||||
// Swipe vers le haut : plier
|
||||
setState(() {
|
||||
_calendarCollapsed = true;
|
||||
});
|
||||
} else if (details.primaryVelocity! > 200) {
|
||||
// Swipe vers le bas : déplier
|
||||
setState(() {
|
||||
_calendarCollapsed = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
onHorizontalDragEnd: (details) {
|
||||
if (details.primaryVelocity != null) {
|
||||
if (details.primaryVelocity! < -200) {
|
||||
// Swipe gauche : mois suivant
|
||||
setState(() {
|
||||
_focusedDay =
|
||||
DateTime(_focusedDay.year, _focusedDay.month + 1, 1);
|
||||
});
|
||||
} else if (details.primaryVelocity! > 200) {
|
||||
// Swipe droite : mois précédent
|
||||
setState(() {
|
||||
_focusedDay =
|
||||
DateTime(_focusedDay.year, _focusedDay.month - 1, 1);
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
child: Stack(
|
||||
children: [
|
||||
// Calendrier + détails en dessous
|
||||
AnimatedPositioned(
|
||||
duration: const Duration(milliseconds: 400),
|
||||
curve: Curves.easeInOut,
|
||||
top: _calendarCollapsed ? -600 : 0, // cache le calendrier en haut
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: _calendarCollapsed ? 0 : null,
|
||||
child: Container(
|
||||
height: MediaQuery.of(context).size.height,
|
||||
child: Column(
|
||||
children: [
|
||||
_buildMonthHeader(context),
|
||||
if (!_calendarCollapsed)
|
||||
// Ajout d'un GestureDetector pour swipe horizontal sur le calendrier
|
||||
GestureDetector(
|
||||
onHorizontalDragEnd: (details) {
|
||||
if (details.primaryVelocity != null) {
|
||||
if (details.primaryVelocity! < -200) {
|
||||
// Swipe gauche : mois suivant
|
||||
setState(() {
|
||||
_focusedDay = DateTime(
|
||||
_focusedDay.year, _focusedDay.month + 1, 1);
|
||||
});
|
||||
} else if (details.primaryVelocity! > 200) {
|
||||
// Swipe droite : mois précédent
|
||||
setState(() {
|
||||
_focusedDay = DateTime(
|
||||
_focusedDay.year, _focusedDay.month - 1, 1);
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMonthHeader(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.chevron_left,
|
||||
color: AppColors.rouge, size: 28),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_focusedDay =
|
||||
DateTime(_focusedDay.year, _focusedDay.month - 1, 1);
|
||||
});
|
||||
},
|
||||
),
|
||||
Expanded(
|
||||
child: EventDetails(
|
||||
event: _selectedEvent!,
|
||||
selectedDate: _selectedDay,
|
||||
events: eventProvider.events,
|
||||
onSelectEvent: (event, date) {
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_selectedEvent = event;
|
||||
_selectedDay = date;
|
||||
_calendarCollapsed = !_calendarCollapsed;
|
||||
});
|
||||
},
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
_getMonthName(_focusedDay.month),
|
||||
style: const TextStyle(
|
||||
color: AppColors.rouge,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Icon(
|
||||
_calendarCollapsed
|
||||
? Icons.keyboard_arrow_down
|
||||
: Icons.keyboard_arrow_up,
|
||||
color: AppColors.rouge,
|
||||
size: 26,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_selectedEvent == null && _selectedDay != null)
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Text('Aucun événement ne démarre à cette date'),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.chevron_right,
|
||||
color: AppColors.rouge, size: 28),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_focusedDay =
|
||||
DateTime(_focusedDay.year, _focusedDay.month + 1, 1);
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _getMonthName(int month) {
|
||||
switch (month) {
|
||||
case 1:
|
||||
return 'Janvier';
|
||||
case 2:
|
||||
return 'Février';
|
||||
case 3:
|
||||
return 'Mars';
|
||||
case 4:
|
||||
return 'Avril';
|
||||
case 5:
|
||||
return 'Mai';
|
||||
case 6:
|
||||
return 'Juin';
|
||||
case 7:
|
||||
return 'Juillet';
|
||||
case 8:
|
||||
return 'Août';
|
||||
case 9:
|
||||
return 'Septembre';
|
||||
case 10:
|
||||
return 'Octobre';
|
||||
case 11:
|
||||
return 'Novembre';
|
||||
case 12:
|
||||
return 'Décembre';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildCalendar() {
|
||||
final eventProvider = Provider.of<EventProvider>(context);
|
||||
|
||||
|
849
em2rp/lib/views/event_add_page.dart
Normal file
849
em2rp/lib/views/event_add_page.dart
Normal file
@ -0,0 +1,849 @@
|
||||
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 EventAddEditPage extends StatefulWidget {
|
||||
final EventModel? event;
|
||||
const EventAddEditPage({super.key, this.event});
|
||||
|
||||
@override
|
||||
State<EventAddEditPage> createState() => _EventAddEditPageState();
|
||||
}
|
||||
|
||||
class _EventAddEditPageState extends State<EventAddEditPage> {
|
||||
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;
|
||||
|
||||
bool get isEditMode => widget.event != null;
|
||||
|
||||
@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();
|
||||
if (isEditMode) {
|
||||
final e = widget.event!;
|
||||
_nameController.text = e.name;
|
||||
_descriptionController.text = e.description;
|
||||
_basePriceController.text = e.basePrice.toStringAsFixed(2);
|
||||
_installationController.text = e.installationTime.toString();
|
||||
_disassemblyController.text = e.disassemblyTime.toString();
|
||||
_addressController.text = e.address;
|
||||
_startDateTime = e.startDateTime;
|
||||
_endDateTime = e.endDateTime;
|
||||
_selectedEventType = e.eventTypeId.isNotEmpty ? e.eventTypeId : null;
|
||||
_selectedUserIds = e.workforce.map((ref) => ref.id).toList();
|
||||
_uploadedFiles = List<Map<String, String>>.from(e.documents);
|
||||
_selectedOptions = List<Map<String, dynamic>>.from(e.options);
|
||||
_selectedStatus = e.status;
|
||||
} else {
|
||||
_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);
|
||||
if (isEditMode) {
|
||||
// Edition : on met à jour l'événement existant
|
||||
final updatedEvent = EventModel(
|
||||
id: widget.event!.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 = FirebaseFirestore.instance
|
||||
.collection('events')
|
||||
.doc(widget.event!.id);
|
||||
await docRef.update(updatedEvent.toMap());
|
||||
// Gestion des fichiers (si besoin, à adapter selon ta logique)
|
||||
// ...
|
||||
setState(() {
|
||||
_success = "Événement modifié avec succès !";
|
||||
});
|
||||
if (context.mounted) Navigator.of(context).pop();
|
||||
} else {
|
||||
// Création : logique existante
|
||||
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 sauvegarde : $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:
|
||||
Text(isEditMode ? 'Modifier un événement' : 'Créer un événement'),
|
||||
),
|
||||
body: Center(
|
||||
child: SingleChildScrollView(
|
||||
child: (isMobile
|
||||
? Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16, vertical: 12),
|
||||
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),
|
||||
)
|
||||
: Text(isEditMode ? 'Enregistrer' : 'Créer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (!isEditMode)
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -9,6 +9,10 @@ import 'package:latlong2/latlong.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:em2rp/views/widgets/user_management/user_card.dart';
|
||||
import 'package:em2rp/models/user_model.dart';
|
||||
import 'package:em2rp/views/widgets/user_management/user_multi_select_widget.dart';
|
||||
import 'package:em2rp/views/event_add_page.dart';
|
||||
|
||||
class EventDetails extends StatelessWidget {
|
||||
final EventModel event;
|
||||
@ -35,6 +39,7 @@ class EventDetails extends StatelessWidget {
|
||||
final currentIndex = sortedEvents.indexWhere((e) => e.id == event.id);
|
||||
final localUserProvider = Provider.of<LocalUserProvider>(context);
|
||||
final isAdmin = localUserProvider.hasPermission('view_all_users');
|
||||
final canViewPrices = localUserProvider.hasPermission('view_event_prices');
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.all(16),
|
||||
@ -92,6 +97,21 @@ class EventDetails extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
_buildStatusIcon(event.status),
|
||||
const SizedBox(width: 8),
|
||||
Spacer(),
|
||||
if (Provider.of<LocalUserProvider>(context, listen: false)
|
||||
.hasPermission('edit_event'))
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit, color: AppColors.rouge),
|
||||
tooltip: 'Modifier',
|
||||
onPressed: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => EventAddEditPage(event: event),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
if (Provider.of<LocalUserProvider>(context, listen: false)
|
||||
@ -106,6 +126,18 @@ class EventDetails extends StatelessWidget {
|
||||
.collection('events')
|
||||
.doc(event.id)
|
||||
.update({'status': eventStatusToString(newStatus)});
|
||||
// Recharge l'événement depuis Firestore et notifie le parent
|
||||
final snap = await FirebaseFirestore.instance
|
||||
.collection('events')
|
||||
.doc(event.id)
|
||||
.get();
|
||||
final updatedEvent =
|
||||
EventModel.fromMap(snap.data()!, event.id);
|
||||
onSelectEvent(updatedEvent,
|
||||
selectedDate ?? updatedEvent.startDateTime);
|
||||
// Met à jour uniquement l'événement dans le provider (rafraîchissement local et fluide)
|
||||
await Provider.of<EventProvider>(context, listen: false)
|
||||
.updateEvent(updatedEvent);
|
||||
},
|
||||
),
|
||||
),
|
||||
@ -127,12 +159,13 @@ class EventDetails extends StatelessWidget {
|
||||
'Date de fin',
|
||||
dateFormat.format(event.endDateTime),
|
||||
),
|
||||
_buildInfoRow(
|
||||
context,
|
||||
Icons.euro,
|
||||
'Prix de base',
|
||||
currencyFormat.format(event.basePrice),
|
||||
),
|
||||
if (canViewPrices)
|
||||
_buildInfoRow(
|
||||
context,
|
||||
Icons.euro,
|
||||
'Prix de base',
|
||||
currencyFormat.format(event.basePrice),
|
||||
),
|
||||
if (event.options.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text('Options sélectionnées',
|
||||
@ -154,56 +187,62 @@ class EventDetails extends StatelessWidget {
|
||||
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,
|
||||
),
|
||||
),
|
||||
trailing: canViewPrices
|
||||
? Text(
|
||||
(isNegative ? '- ' : '+ ') +
|
||||
currencyFormat.format(price.abs()),
|
||||
style: TextStyle(
|
||||
color: isNegative
|
||||
? Colors.red
|
||||
: AppColors.noir,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
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 : ',
|
||||
if (canViewPrices) ...[
|
||||
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.noir,
|
||||
color: AppColors.rouge,
|
||||
fontWeight: FontWeight.bold,
|
||||
)),
|
||||
Text(
|
||||
currencyFormat.format(total),
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(
|
||||
color: AppColors.rouge,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
_buildInfoRow(
|
||||
context,
|
||||
@ -317,6 +356,9 @@ class EventDetails extends StatelessWidget {
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
// --- EQUIPE SECTION ---
|
||||
const SizedBox(height: 16),
|
||||
EquipeSection(workforce: event.workforce),
|
||||
],
|
||||
],
|
||||
),
|
||||
@ -628,44 +670,43 @@ class _FirestoreStatusButton extends StatefulWidget {
|
||||
final String eventId;
|
||||
final EventStatus currentStatus;
|
||||
final Future<void> Function(EventStatus) onStatusChanged;
|
||||
const _FirestoreStatusButton(
|
||||
{required this.eventId,
|
||||
required this.currentStatus,
|
||||
required this.onStatusChanged});
|
||||
const _FirestoreStatusButton({
|
||||
required this.eventId,
|
||||
required this.currentStatus,
|
||||
required this.onStatusChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_FirestoreStatusButton> createState() => _FirestoreStatusButtonState();
|
||||
}
|
||||
|
||||
class _FirestoreStatusButtonState extends State<_FirestoreStatusButton> {
|
||||
late EventStatus _status;
|
||||
bool _loading = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_status = widget.currentStatus;
|
||||
Future<void> changerStatut(EventStatus nouveau) async {
|
||||
if (widget.currentStatus == nouveau) return;
|
||||
setState(() => _loading = true);
|
||||
await widget.onStatusChanged(nouveau);
|
||||
setState(() => _loading = false);
|
||||
}
|
||||
|
||||
Future<void> changerStatut(EventStatus nouveau) async {
|
||||
if (_status == nouveau) return;
|
||||
setState(() => _loading = true);
|
||||
await FirebaseFirestore.instance
|
||||
.collection('events')
|
||||
.doc(widget.eventId)
|
||||
.update({'status': eventStatusToString(nouveau)});
|
||||
setState(() {
|
||||
_status = nouveau;
|
||||
_loading = false;
|
||||
});
|
||||
@override
|
||||
void didUpdateWidget(covariant _FirestoreStatusButton oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
// Si l'événement change, on arrête le loading (sécurité UX)
|
||||
if (oldWidget.eventId != widget.eventId ||
|
||||
oldWidget.currentStatus != widget.currentStatus) {
|
||||
if (_loading) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final status = widget.currentStatus;
|
||||
String texte;
|
||||
Color couleurFond;
|
||||
List<Widget> enfants = [];
|
||||
switch (_status) {
|
||||
switch (status) {
|
||||
case EventStatus.waitingForApproval:
|
||||
texte = "En Attente";
|
||||
couleurFond = Colors.yellow.shade600;
|
||||
@ -754,3 +795,84 @@ class _FirestoreStatusButtonState extends State<_FirestoreStatusButton> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class EquipeSection extends StatelessWidget {
|
||||
final List workforce;
|
||||
const EquipeSection({super.key, required this.workforce});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (workforce.isEmpty) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Equipe',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
color: Colors.black,
|
||||
fontWeight: FontWeight.bold,
|
||||
)),
|
||||
const SizedBox(height: 8),
|
||||
Text('Aucun membre assigné.',
|
||||
style: Theme.of(context).textTheme.bodyMedium),
|
||||
],
|
||||
);
|
||||
}
|
||||
return FutureBuilder<List<UserModel>>(
|
||||
future: _fetchUsers(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 16),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
if (snapshot.hasError) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: Text(
|
||||
snapshot.error.toString().contains('permission-denied')
|
||||
? "Vous n'avez pas la permission de voir tous les membres de l'équipe."
|
||||
: "Erreur lors du chargement de l'équipe : ${snapshot.error}",
|
||||
style: const TextStyle(color: Colors.red),
|
||||
),
|
||||
);
|
||||
}
|
||||
final users = snapshot.data ?? [];
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Equipe',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
color: Colors.black,
|
||||
fontWeight: FontWeight.bold,
|
||||
)),
|
||||
const SizedBox(height: 8),
|
||||
if (users.isEmpty)
|
||||
Text('Aucun membre assigné.',
|
||||
style: Theme.of(context).textTheme.bodyMedium),
|
||||
if (users.isNotEmpty)
|
||||
UserChipsList(
|
||||
users: users,
|
||||
showRemove: false,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<UserModel>> _fetchUsers() async {
|
||||
final firestore = FirebaseFirestore.instance;
|
||||
List<UserModel> users = [];
|
||||
for (final ref in workforce) {
|
||||
try {
|
||||
final doc = await firestore.doc(ref.path).get();
|
||||
if (doc.exists) {
|
||||
users.add(
|
||||
UserModel.fromMap(doc.data() as Map<String, dynamic>, doc.id));
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
return users;
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,139 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:em2rp/models/event_model.dart';
|
||||
|
||||
class MobileCalendarView extends StatelessWidget {
|
||||
final DateTime focusedDay;
|
||||
final DateTime? selectedDay;
|
||||
final List<EventModel> events;
|
||||
final void Function(DateTime) onDaySelected;
|
||||
|
||||
const MobileCalendarView({
|
||||
super.key,
|
||||
required this.focusedDay,
|
||||
required this.selectedDay,
|
||||
required this.events,
|
||||
required this.onDaySelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final daysInMonth =
|
||||
DateUtils.getDaysInMonth(focusedDay.year, focusedDay.month);
|
||||
final firstDayOfMonth = DateTime(focusedDay.year, focusedDay.month, 1);
|
||||
final firstWeekday = firstDayOfMonth.weekday;
|
||||
final days = <DateTime>[];
|
||||
// Ajoute les jours vides avant le 1er du mois (pour aligner sur le bon jour de la semaine)
|
||||
for (int i = 1; i < firstWeekday; i++) {
|
||||
days.add(DateTime(0)); // jour vide
|
||||
}
|
||||
// Ajoute les jours du mois
|
||||
for (int i = 0; i < daysInMonth; i++) {
|
||||
days.add(DateTime(focusedDay.year, focusedDay.month, i + 1));
|
||||
}
|
||||
// Complète la dernière semaine si besoin
|
||||
while (days.length % 7 != 0) {
|
||||
days.add(DateTime(0));
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: List.generate(7, (i) {
|
||||
const daysShort = ['L', 'M', 'M', 'J', 'V', 'S', 'D'];
|
||||
return Expanded(
|
||||
child: Center(
|
||||
child: Text(daysShort[i],
|
||||
style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
...List.generate(days.length ~/ 7, (week) {
|
||||
return Row(
|
||||
children: List.generate(7, (i) {
|
||||
final day = days[week * 7 + i];
|
||||
if (day.year == 0) {
|
||||
return const Expanded(child: SizedBox.shrink());
|
||||
}
|
||||
final isSelected =
|
||||
selectedDay != null && _isSameDay(day, selectedDay!);
|
||||
final eventsForDay = events
|
||||
.where((e) => _isSameDay(e.startDateTime, day))
|
||||
.toList();
|
||||
return Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () => onDaySelected(day),
|
||||
child: Container(
|
||||
color: Colors.transparent,
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
decoration: isSelected
|
||||
? BoxDecoration(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primary
|
||||
.withOpacity(0.15),
|
||||
shape: BoxShape.circle,
|
||||
)
|
||||
: null,
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: Text(
|
||||
'${day.day}',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: eventsForDay
|
||||
.map((event) => Container(
|
||||
margin: const EdgeInsets.symmetric(
|
||||
horizontal: 1),
|
||||
width: 6,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(
|
||||
color: _getColorForStatus(event.status),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
bool _isSameDay(DateTime a, DateTime b) {
|
||||
return a.year == b.year && a.month == b.month && a.day == b.day;
|
||||
}
|
||||
|
||||
Color _getColorForStatus(EventStatus status) {
|
||||
switch (status) {
|
||||
case EventStatus.confirmed:
|
||||
return Colors.green;
|
||||
case EventStatus.canceled:
|
||||
return Colors.red;
|
||||
case EventStatus.waitingForApproval:
|
||||
default:
|
||||
return Colors.amber;
|
||||
}
|
||||
}
|
||||
}
|
@ -283,10 +283,6 @@ class WeekView extends StatelessWidget {
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Icon(_getStatusIcon(e.event.status),
|
||||
color: _getStatusTextColor(e.event.status),
|
||||
size: 16),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
@ -8,6 +8,7 @@ class OptionSelectorWidget extends StatefulWidget {
|
||||
final ValueChanged<List<Map<String, dynamic>>> onChanged;
|
||||
final void Function(String name)? onRemove;
|
||||
final bool eventTypeRequired;
|
||||
final bool isMobile;
|
||||
|
||||
const OptionSelectorWidget({
|
||||
super.key,
|
||||
@ -16,6 +17,7 @@ class OptionSelectorWidget extends StatefulWidget {
|
||||
required this.onChanged,
|
||||
this.onRemove,
|
||||
this.eventTypeRequired = false,
|
||||
this.isMobile = false,
|
||||
});
|
||||
|
||||
@override
|
||||
@ -79,53 +81,56 @@ class _OptionSelectorWidgetState extends State<OptionSelectorWidget> {
|
||||
Text('Options sélectionnées',
|
||||
style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
Column(
|
||||
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)),
|
||||
],
|
||||
),
|
||||
.map((opt) => Card(
|
||||
elevation: widget.isMobile ? 0 : 2,
|
||||
margin: EdgeInsets.symmetric(
|
||||
vertical: widget.isMobile ? 4 : 8,
|
||||
horizontal: widget.isMobile ? 0 : 8),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius:
|
||||
BorderRadius.circular(widget.isMobile ? 8 : 12)),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(widget.isMobile ? 8.0 : 12.0),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(opt['name'] ?? '',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold)),
|
||||
if (opt['details'] != null &&
|
||||
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),
|
||||
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);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
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);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
))
|
||||
|
@ -70,26 +70,15 @@ class _UserMultiSelectState extends State<_UserMultiSelect> {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: selectedUsers
|
||||
.map((user) => Chip(
|
||||
avatar: ProfilePictureWidget(userId: user.uid, radius: 28),
|
||||
label: Text('${user.firstName} ${user.lastName}',
|
||||
style: const TextStyle(fontSize: 16)),
|
||||
labelPadding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
deleteIcon: const Icon(Icons.close, size: 20),
|
||||
onDeleted: () {
|
||||
final newList = List<String>.from(widget.selectedUserIds)
|
||||
..remove(user.uid);
|
||||
widget.onChanged(newList);
|
||||
},
|
||||
backgroundColor: Colors.grey[200],
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
))
|
||||
.toList(),
|
||||
UserChipsList(
|
||||
users: selectedUsers,
|
||||
selectedUserIds: widget.selectedUserIds,
|
||||
showRemove: true,
|
||||
onRemove: (uid) {
|
||||
final newList = List<String>.from(widget.selectedUserIds)
|
||||
..remove(uid);
|
||||
widget.onChanged(newList);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton.icon(
|
||||
@ -188,3 +177,43 @@ class _UserPickerDialogState extends State<_UserPickerDialog> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class UserChipsList extends StatelessWidget {
|
||||
final List<UserModel> users;
|
||||
final List<String> selectedUserIds;
|
||||
final ValueChanged<String>? onRemove;
|
||||
final bool showRemove;
|
||||
final double avatarRadius;
|
||||
const UserChipsList({
|
||||
super.key,
|
||||
required this.users,
|
||||
this.selectedUserIds = const [],
|
||||
this.onRemove,
|
||||
this.showRemove = false,
|
||||
this.avatarRadius = 28,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: users
|
||||
.map((user) => Chip(
|
||||
avatar: ProfilePictureWidget(
|
||||
userId: user.uid, radius: avatarRadius),
|
||||
label: Text('${user.firstName} ${user.lastName}',
|
||||
style: const TextStyle(fontSize: 16)),
|
||||
labelPadding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
deleteIcon:
|
||||
showRemove ? const Icon(Icons.close, size: 20) : null,
|
||||
onDeleted: showRemove && onRemove != null
|
||||
? () => onRemove!(user.uid)
|
||||
: null,
|
||||
backgroundColor: Colors.grey[200],
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user