Compare commits
9 Commits
49dffff1bf
...
main
Author | SHA1 | Date | |
---|---|---|---|
080fb7d077 | |||
57c59c911a | |||
acab16e101 | |||
9a9c932262 | |||
004d442e67 | |||
77d0d5cc81 | |||
b80a6d2623 | |||
50a38816d3 | |||
9489183b68 |
@ -13,7 +13,7 @@ import 'views/user_management_page.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'providers/local_user_provider.dart';
|
import 'providers/local_user_provider.dart';
|
||||||
import 'services/user_service.dart';
|
import 'services/user_service.dart';
|
||||||
import 'pages/auth/reset_password_page.dart';
|
import 'views/reset_password_page.dart';
|
||||||
import 'config/env.dart';
|
import 'config/env.dart';
|
||||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
|
|
||||||
|
@ -1,13 +1,43 @@
|
|||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
|
enum EventStatus {
|
||||||
|
confirmed,
|
||||||
|
canceled,
|
||||||
|
waitingForApproval,
|
||||||
|
}
|
||||||
|
|
||||||
|
String eventStatusToString(EventStatus status) {
|
||||||
|
switch (status) {
|
||||||
|
case EventStatus.confirmed:
|
||||||
|
return 'CONFIRMED';
|
||||||
|
case EventStatus.canceled:
|
||||||
|
return 'CANCELED';
|
||||||
|
case EventStatus.waitingForApproval:
|
||||||
|
default:
|
||||||
|
return 'WAITING_FOR_APPROVAL';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
EventStatus eventStatusFromString(String? status) {
|
||||||
|
switch (status) {
|
||||||
|
case 'CONFIRMED':
|
||||||
|
return EventStatus.confirmed;
|
||||||
|
case 'CANCELED':
|
||||||
|
return EventStatus.canceled;
|
||||||
|
case 'WAITING_FOR_APPROVAL':
|
||||||
|
default:
|
||||||
|
return EventStatus.waitingForApproval;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class EventModel {
|
class EventModel {
|
||||||
final String id;
|
final String id;
|
||||||
final String name;
|
final String name;
|
||||||
final String description;
|
final String description;
|
||||||
final DateTime startDateTime;
|
final DateTime startDateTime;
|
||||||
final DateTime endDateTime;
|
final DateTime endDateTime;
|
||||||
final double price;
|
final double basePrice;
|
||||||
final int installationTime;
|
final int installationTime;
|
||||||
final int disassemblyTime;
|
final int disassemblyTime;
|
||||||
final String eventTypeId;
|
final String eventTypeId;
|
||||||
@ -17,6 +47,8 @@ class EventModel {
|
|||||||
final double longitude;
|
final double longitude;
|
||||||
final List<DocumentReference> workforce;
|
final List<DocumentReference> workforce;
|
||||||
final List<Map<String, String>> documents;
|
final List<Map<String, String>> documents;
|
||||||
|
final List<Map<String, dynamic>> options;
|
||||||
|
final EventStatus status;
|
||||||
|
|
||||||
EventModel({
|
EventModel({
|
||||||
required this.id,
|
required this.id,
|
||||||
@ -24,7 +56,7 @@ class EventModel {
|
|||||||
required this.description,
|
required this.description,
|
||||||
required this.startDateTime,
|
required this.startDateTime,
|
||||||
required this.endDateTime,
|
required this.endDateTime,
|
||||||
required this.price,
|
required this.basePrice,
|
||||||
required this.installationTime,
|
required this.installationTime,
|
||||||
required this.disassemblyTime,
|
required this.disassemblyTime,
|
||||||
required this.eventTypeId,
|
required this.eventTypeId,
|
||||||
@ -34,6 +66,8 @@ class EventModel {
|
|||||||
required this.longitude,
|
required this.longitude,
|
||||||
required this.workforce,
|
required this.workforce,
|
||||||
required this.documents,
|
required this.documents,
|
||||||
|
this.options = const [],
|
||||||
|
this.status = EventStatus.waitingForApproval,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory EventModel.fromMap(Map<String, dynamic> map, String id) {
|
factory EventModel.fromMap(Map<String, dynamic> map, String id) {
|
||||||
@ -47,14 +81,25 @@ class EventModel {
|
|||||||
if (e is Map) {
|
if (e is Map) {
|
||||||
return Map<String, String>.from(e as Map);
|
return Map<String, String>.from(e as Map);
|
||||||
} else if (e is String) {
|
} else if (e is String) {
|
||||||
final fileName =
|
final fileName = Uri.decodeComponent(
|
||||||
Uri.decodeComponent(e.split('/').last.split('?').first);
|
e.split('/').last.split('?').first,
|
||||||
|
);
|
||||||
return {'name': fileName, 'url': e};
|
return {'name': fileName, 'url': e};
|
||||||
} else {
|
} else {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
}).toList()
|
}).toList()
|
||||||
: <Map<String, String>>[];
|
: <Map<String, String>>[];
|
||||||
|
final optionsRaw = map['options'] ?? [];
|
||||||
|
final options = optionsRaw is List
|
||||||
|
? optionsRaw.map<Map<String, dynamic>>((e) {
|
||||||
|
if (e is Map) {
|
||||||
|
return Map<String, dynamic>.from(e as Map);
|
||||||
|
} else {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}).toList()
|
||||||
|
: <Map<String, dynamic>>[];
|
||||||
return EventModel(
|
return EventModel(
|
||||||
id: id,
|
id: id,
|
||||||
name: map['Name'] ?? '',
|
name: map['Name'] ?? '',
|
||||||
@ -62,7 +107,7 @@ class EventModel {
|
|||||||
startDateTime: startTimestamp?.toDate() ?? DateTime.now(),
|
startDateTime: startTimestamp?.toDate() ?? DateTime.now(),
|
||||||
endDateTime: endTimestamp?.toDate() ??
|
endDateTime: endTimestamp?.toDate() ??
|
||||||
DateTime.now().add(const Duration(hours: 1)),
|
DateTime.now().add(const Duration(hours: 1)),
|
||||||
price: (map['Price'] ?? 0.0).toDouble(),
|
basePrice: (map['BasePrice'] ?? map['Price'] ?? 0.0).toDouble(),
|
||||||
installationTime: map['InstallationTime'] ?? 0,
|
installationTime: map['InstallationTime'] ?? 0,
|
||||||
disassemblyTime: map['DisassemblyTime'] ?? 0,
|
disassemblyTime: map['DisassemblyTime'] ?? 0,
|
||||||
eventTypeId: map['EventType'] is DocumentReference
|
eventTypeId: map['EventType'] is DocumentReference
|
||||||
@ -76,6 +121,8 @@ class EventModel {
|
|||||||
longitude: (map['Longitude'] ?? 0.0).toDouble(),
|
longitude: (map['Longitude'] ?? 0.0).toDouble(),
|
||||||
workforce: workforceRefs.whereType<DocumentReference>().toList(),
|
workforce: workforceRefs.whereType<DocumentReference>().toList(),
|
||||||
documents: docs,
|
documents: docs,
|
||||||
|
options: options,
|
||||||
|
status: eventStatusFromString(map['status'] as String?),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -85,7 +132,7 @@ class EventModel {
|
|||||||
'Description': description,
|
'Description': description,
|
||||||
'StartDateTime': Timestamp.fromDate(startDateTime),
|
'StartDateTime': Timestamp.fromDate(startDateTime),
|
||||||
'EndDateTime': Timestamp.fromDate(endDateTime),
|
'EndDateTime': Timestamp.fromDate(endDateTime),
|
||||||
'Price': price,
|
'BasePrice': basePrice,
|
||||||
'InstallationTime': installationTime,
|
'InstallationTime': installationTime,
|
||||||
'DisassemblyTime': disassemblyTime,
|
'DisassemblyTime': disassemblyTime,
|
||||||
'EventType': eventTypeId,
|
'EventType': eventTypeId,
|
||||||
@ -96,6 +143,8 @@ class EventModel {
|
|||||||
'Longitude': longitude,
|
'Longitude': longitude,
|
||||||
'workforce': workforce,
|
'workforce': workforce,
|
||||||
'documents': documents,
|
'documents': documents,
|
||||||
|
'options': options,
|
||||||
|
'status': eventStatusToString(status),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
42
em2rp/lib/models/option_model.dart
Normal file
42
em2rp/lib/models/option_model.dart
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
|
|
||||||
|
class EventOption {
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final String details;
|
||||||
|
final double valMin;
|
||||||
|
final double valMax;
|
||||||
|
final List<DocumentReference> eventTypes;
|
||||||
|
|
||||||
|
EventOption({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.details,
|
||||||
|
required this.valMin,
|
||||||
|
required this.valMax,
|
||||||
|
required this.eventTypes,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory EventOption.fromMap(Map<String, dynamic> map, String id) {
|
||||||
|
return EventOption(
|
||||||
|
id: id,
|
||||||
|
name: map['name'] ?? '',
|
||||||
|
details: map['details'] ?? '',
|
||||||
|
valMin: (map['valMin'] ?? 0.0).toDouble(),
|
||||||
|
valMax: (map['valMax'] ?? 0.0).toDouble(),
|
||||||
|
eventTypes: (map['eventTypes'] as List<dynamic>? ?? [])
|
||||||
|
.whereType<DocumentReference>()
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toMap() {
|
||||||
|
return {
|
||||||
|
'name': name,
|
||||||
|
'details': details,
|
||||||
|
'valMin': valMin,
|
||||||
|
'valMax': valMax,
|
||||||
|
'eventTypes': eventTypes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@ -20,21 +20,24 @@ class EventProvider with ChangeNotifier {
|
|||||||
print(
|
print(
|
||||||
'Loading events for user: $userId (canViewAllEvents: $canViewAllEvents)');
|
'Loading events for user: $userId (canViewAllEvents: $canViewAllEvents)');
|
||||||
QuerySnapshot eventsSnapshot;
|
QuerySnapshot eventsSnapshot;
|
||||||
if (canViewAllEvents) {
|
// On charge tous les events pour les users non-admins aussi
|
||||||
eventsSnapshot = await _firestore.collection('events').get();
|
eventsSnapshot = await _firestore.collection('events').get();
|
||||||
} else {
|
|
||||||
eventsSnapshot = await _firestore
|
|
||||||
.collection('events')
|
|
||||||
.where('workforce', arrayContains: userId)
|
|
||||||
.get();
|
|
||||||
}
|
|
||||||
|
|
||||||
print('Found ${eventsSnapshot.docs.length} events for user');
|
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()}');
|
print('Event data: ${doc.data()}');
|
||||||
return EventModel.fromMap(doc.data() as Map<String, dynamic>, doc.id);
|
return EventModel.fromMap(doc.data() as Map<String, dynamic>, doc.id);
|
||||||
}).toList();
|
}).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');
|
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: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/utils/colors.dart';
|
||||||
|
|
||||||
class CalendarPage extends StatefulWidget {
|
class CalendarPage extends StatefulWidget {
|
||||||
const CalendarPage({super.key});
|
const CalendarPage({super.key});
|
||||||
@ -24,12 +26,60 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
DateTime _focusedDay = DateTime.now();
|
DateTime _focusedDay = DateTime.now();
|
||||||
DateTime? _selectedDay;
|
DateTime? _selectedDay;
|
||||||
EventModel? _selectedEvent;
|
EventModel? _selectedEvent;
|
||||||
|
bool _calendarCollapsed = false;
|
||||||
|
int _selectedEventIndex = 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
initializeDateFormatting('fr_FR', null);
|
initializeDateFormatting('fr_FR', null);
|
||||||
Future.microtask(() => _loadEvents());
|
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 {
|
Future<void> _loadEvents() async {
|
||||||
@ -69,8 +119,23 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: const CustomAppBar(
|
appBar: CustomAppBar(
|
||||||
title: 'Calendrier',
|
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'),
|
drawer: const MainDrawer(currentPage: '/calendar'),
|
||||||
body: isMobile ? _buildMobileLayout() : _buildDesktopLayout(),
|
body: isMobile ? _buildMobileLayout() : _buildDesktopLayout(),
|
||||||
@ -80,7 +145,7 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
onPressed: () {
|
onPressed: () {
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) => const EventAddPage(),
|
builder: (context) => const EventAddEditPage(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@ -128,37 +193,331 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
|
|
||||||
Widget _buildMobileLayout() {
|
Widget _buildMobileLayout() {
|
||||||
final eventProvider = Provider.of<EventProvider>(context);
|
final eventProvider = Provider.of<EventProvider>(context);
|
||||||
return Column(
|
final eventsForSelectedDay = _selectedDay == null
|
||||||
children: [
|
? []
|
||||||
// Calendrier
|
: eventProvider.events
|
||||||
Expanded(
|
.where((e) =>
|
||||||
child: _buildCalendar(),
|
e.startDateTime.year == _selectedDay!.year &&
|
||||||
),
|
e.startDateTime.month == _selectedDay!.month &&
|
||||||
// Détails de l'événement
|
e.startDateTime.day == _selectedDay!.day)
|
||||||
if (_selectedEvent != null)
|
.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(
|
Expanded(
|
||||||
child: EventDetails(
|
child: GestureDetector(
|
||||||
event: _selectedEvent!,
|
onTap: () {
|
||||||
selectedDate: _selectedDay,
|
|
||||||
events: eventProvider.events,
|
|
||||||
onSelectEvent: (event, date) {
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_selectedEvent = event;
|
_calendarCollapsed = !_calendarCollapsed;
|
||||||
_selectedDay = date;
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
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)
|
IconButton(
|
||||||
Expanded(
|
icon: const Icon(Icons.chevron_right,
|
||||||
child: Center(
|
color: AppColors.rouge, size: 28),
|
||||||
child: Text('Aucun événement ne démarre à cette date'),
|
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() {
|
Widget _buildCalendar() {
|
||||||
final eventProvider = Provider.of<EventProvider>(context);
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
File diff suppressed because it is too large
Load Diff
@ -8,6 +8,11 @@ import 'package:em2rp/providers/event_provider.dart';
|
|||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
import 'package:path/path.dart' as p;
|
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 {
|
class EventDetails extends StatelessWidget {
|
||||||
final EventModel event;
|
final EventModel event;
|
||||||
@ -34,6 +39,7 @@ class EventDetails extends StatelessWidget {
|
|||||||
final currentIndex = sortedEvents.indexWhere((e) => e.id == event.id);
|
final currentIndex = sortedEvents.indexWhere((e) => e.id == event.id);
|
||||||
final localUserProvider = Provider.of<LocalUserProvider>(context);
|
final localUserProvider = Provider.of<LocalUserProvider>(context);
|
||||||
final isAdmin = localUserProvider.hasPermission('view_all_users');
|
final isAdmin = localUserProvider.hasPermission('view_all_users');
|
||||||
|
final canViewPrices = localUserProvider.hasPermission('view_event_prices');
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
margin: const EdgeInsets.all(16),
|
margin: const EdgeInsets.all(16),
|
||||||
@ -80,136 +86,284 @@ class EventDetails extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Row(
|
||||||
event.name,
|
children: [
|
||||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
SelectableText(
|
||||||
color: AppColors.noir,
|
event.name,
|
||||||
fontWeight: FontWeight.bold,
|
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||||
),
|
color: AppColors.noir,
|
||||||
),
|
fontWeight: FontWeight.bold,
|
||||||
const SizedBox(height: 16),
|
),
|
||||||
_buildInfoRow(
|
),
|
||||||
context,
|
const SizedBox(width: 12),
|
||||||
Icons.calendar_today,
|
_buildStatusIcon(event.status),
|
||||||
'Date de début',
|
const SizedBox(width: 8),
|
||||||
dateFormat.format(event.startDateTime),
|
Spacer(),
|
||||||
),
|
if (Provider.of<LocalUserProvider>(context, listen: false)
|
||||||
_buildInfoRow(
|
.hasPermission('edit_event'))
|
||||||
context,
|
IconButton(
|
||||||
Icons.calendar_today,
|
icon: const Icon(Icons.edit, color: AppColors.rouge),
|
||||||
'Date de fin',
|
tooltip: 'Modifier',
|
||||||
dateFormat.format(event.endDateTime),
|
onPressed: () {
|
||||||
),
|
Navigator.of(context).push(
|
||||||
_buildInfoRow(
|
MaterialPageRoute(
|
||||||
context,
|
builder: (context) => EventAddEditPage(event: event),
|
||||||
Icons.euro,
|
),
|
||||||
'Prix',
|
);
|
||||||
currencyFormat.format(event.price),
|
|
||||||
),
|
|
||||||
_buildInfoRow(
|
|
||||||
context,
|
|
||||||
Icons.build,
|
|
||||||
'Temps d\'installation',
|
|
||||||
'${event.installationTime} heures',
|
|
||||||
),
|
|
||||||
_buildInfoRow(
|
|
||||||
context,
|
|
||||||
Icons.construction,
|
|
||||||
'Temps de démontage',
|
|
||||||
'${event.disassemblyTime} heures',
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'Description',
|
|
||||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
|
||||||
color: AppColors.noir,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
event.description,
|
|
||||||
style: Theme.of(context).textTheme.bodyLarge,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'Adresse',
|
|
||||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
|
||||||
color: AppColors.noir,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
event.address,
|
|
||||||
style: Theme.of(context).textTheme.bodyLarge,
|
|
||||||
),
|
|
||||||
if (event.latitude != 0.0 || event.longitude != 0.0) ...[
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
'${event.latitude}° N, ${event.longitude}° E',
|
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
if (event.documents.isNotEmpty) ...[
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text('Documents',
|
|
||||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
|
||||||
color: AppColors.noir, fontWeight: FontWeight.bold)),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: event.documents.map((doc) {
|
|
||||||
final fileName = doc['name'] ?? '';
|
|
||||||
final url = doc['url'] ?? '';
|
|
||||||
final ext = p.extension(fileName).toLowerCase();
|
|
||||||
IconData icon;
|
|
||||||
if ([".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"]
|
|
||||||
.contains(ext)) {
|
|
||||||
icon = Icons.image;
|
|
||||||
} else if (ext == ".pdf") {
|
|
||||||
icon = Icons.picture_as_pdf;
|
|
||||||
} else if ([
|
|
||||||
".txt",
|
|
||||||
".md",
|
|
||||||
".csv",
|
|
||||||
".json",
|
|
||||||
".xml",
|
|
||||||
".docx",
|
|
||||||
".doc",
|
|
||||||
".xls",
|
|
||||||
".xlsx",
|
|
||||||
".ppt",
|
|
||||||
".pptx"
|
|
||||||
].contains(ext)) {
|
|
||||||
icon = Icons.description;
|
|
||||||
} else {
|
|
||||||
icon = Icons.attach_file;
|
|
||||||
}
|
|
||||||
return ListTile(
|
|
||||||
leading: Icon(icon, color: Colors.blueGrey),
|
|
||||||
title: Text(fileName, overflow: TextOverflow.ellipsis),
|
|
||||||
trailing: IconButton(
|
|
||||||
icon: const Icon(Icons.download),
|
|
||||||
onPressed: () async {
|
|
||||||
if (await canLaunchUrl(Uri.parse(url))) {
|
|
||||||
await launchUrl(Uri.parse(url),
|
|
||||||
mode: LaunchMode.externalApplication);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
onTap: () async {
|
|
||||||
if (await canLaunchUrl(Uri.parse(url))) {
|
|
||||||
await launchUrl(Uri.parse(url),
|
|
||||||
mode: LaunchMode.externalApplication);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
contentPadding: EdgeInsets.zero,
|
),
|
||||||
dense: true,
|
],
|
||||||
);
|
),
|
||||||
}).toList(),
|
if (Provider.of<LocalUserProvider>(context, listen: false)
|
||||||
|
.hasPermission('change_event_status'))
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12.0),
|
||||||
|
child: _FirestoreStatusButton(
|
||||||
|
eventId: event.id,
|
||||||
|
currentStatus: event.status,
|
||||||
|
onStatusChanged: (newStatus) async {
|
||||||
|
await FirebaseFirestore.instance
|
||||||
|
.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);
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
const SizedBox(height: 16),
|
||||||
|
Expanded(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildInfoRow(
|
||||||
|
context,
|
||||||
|
Icons.calendar_today,
|
||||||
|
'Date de début',
|
||||||
|
dateFormat.format(event.startDateTime),
|
||||||
|
),
|
||||||
|
_buildInfoRow(
|
||||||
|
context,
|
||||||
|
Icons.calendar_today,
|
||||||
|
'Date de fin',
|
||||||
|
dateFormat.format(event.endDateTime),
|
||||||
|
),
|
||||||
|
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',
|
||||||
|
style:
|
||||||
|
Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
|
color: AppColors.noir,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
)),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: event.options.map((opt) {
|
||||||
|
final price = (opt['price'] ?? 0.0) as num;
|
||||||
|
final isNegative = price < 0;
|
||||||
|
return ListTile(
|
||||||
|
leading: Icon(Icons.tune,
|
||||||
|
color:
|
||||||
|
isNegative ? Colors.red : AppColors.rouge),
|
||||||
|
title: Text(opt['name'] ?? '',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
subtitle: Text(opt['details'] ?? ''),
|
||||||
|
trailing: 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(),
|
||||||
|
),
|
||||||
|
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.rouge,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
_buildInfoRow(
|
||||||
|
context,
|
||||||
|
Icons.build,
|
||||||
|
'Temps d\'installation',
|
||||||
|
'${event.installationTime} heures',
|
||||||
|
),
|
||||||
|
_buildInfoRow(
|
||||||
|
context,
|
||||||
|
Icons.construction,
|
||||||
|
'Temps de démontage',
|
||||||
|
'${event.disassemblyTime} heures',
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Description',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
|
color: AppColors.noir,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
SelectableText(
|
||||||
|
event.description,
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Adresse',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
|
color: AppColors.noir,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
SelectableText(
|
||||||
|
event.address,
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
|
),
|
||||||
|
if (event.latitude != 0.0 || event.longitude != 0.0) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
SelectableText(
|
||||||
|
'${event.latitude}° N, ${event.longitude}° E',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (event.documents.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text('Documents',
|
||||||
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.titleLarge
|
||||||
|
?.copyWith(
|
||||||
|
color: AppColors.noir,
|
||||||
|
fontWeight: FontWeight.bold)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: event.documents.map((doc) {
|
||||||
|
final fileName = doc['name'] ?? '';
|
||||||
|
final url = doc['url'] ?? '';
|
||||||
|
final ext = p.extension(fileName).toLowerCase();
|
||||||
|
IconData icon;
|
||||||
|
if ([".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"]
|
||||||
|
.contains(ext)) {
|
||||||
|
icon = Icons.image;
|
||||||
|
} else if (ext == ".pdf") {
|
||||||
|
icon = Icons.picture_as_pdf;
|
||||||
|
} else if ([
|
||||||
|
".txt",
|
||||||
|
".md",
|
||||||
|
".csv",
|
||||||
|
".json",
|
||||||
|
".xml",
|
||||||
|
".docx",
|
||||||
|
".doc",
|
||||||
|
".xls",
|
||||||
|
".xlsx",
|
||||||
|
".ppt",
|
||||||
|
".pptx"
|
||||||
|
].contains(ext)) {
|
||||||
|
icon = Icons.description;
|
||||||
|
} else {
|
||||||
|
icon = Icons.attach_file;
|
||||||
|
}
|
||||||
|
return ListTile(
|
||||||
|
leading: Icon(icon, color: Colors.blueGrey),
|
||||||
|
title: SelectableText(
|
||||||
|
fileName,
|
||||||
|
maxLines: 1,
|
||||||
|
textAlign: TextAlign.left,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
trailing: IconButton(
|
||||||
|
icon: const Icon(Icons.download),
|
||||||
|
onPressed: () async {
|
||||||
|
if (await canLaunchUrl(Uri.parse(url))) {
|
||||||
|
await launchUrl(Uri.parse(url),
|
||||||
|
mode: LaunchMode.externalApplication);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
onTap: () async {
|
||||||
|
if (await canLaunchUrl(Uri.parse(url))) {
|
||||||
|
await launchUrl(Uri.parse(url),
|
||||||
|
mode: LaunchMode.externalApplication);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
dense: true,
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
// --- EQUIPE SECTION ---
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
EquipeSection(workforce: event.workforce),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -243,6 +397,34 @@ class EventDetails extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildStatusIcon(EventStatus status) {
|
||||||
|
Color color;
|
||||||
|
IconData icon;
|
||||||
|
String tooltip;
|
||||||
|
switch (status) {
|
||||||
|
case EventStatus.confirmed:
|
||||||
|
color = Colors.green;
|
||||||
|
icon = Icons.check_circle;
|
||||||
|
tooltip = 'Confirmé';
|
||||||
|
break;
|
||||||
|
case EventStatus.canceled:
|
||||||
|
color = Colors.red;
|
||||||
|
icon = Icons.cancel;
|
||||||
|
tooltip = 'Annulé';
|
||||||
|
break;
|
||||||
|
case EventStatus.waitingForApproval:
|
||||||
|
default:
|
||||||
|
color = Colors.amber;
|
||||||
|
icon = Icons.hourglass_empty;
|
||||||
|
tooltip = 'En attente de validation';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return Tooltip(
|
||||||
|
message: tooltip,
|
||||||
|
child: Icon(icon, color: color, size: 28),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class EventAddDialog extends StatefulWidget {
|
class EventAddDialog extends StatefulWidget {
|
||||||
@ -298,7 +480,7 @@ class _EventAddDialogState extends State<EventAddDialog> {
|
|||||||
description: _descriptionController.text.trim(),
|
description: _descriptionController.text.trim(),
|
||||||
startDateTime: _startDateTime!,
|
startDateTime: _startDateTime!,
|
||||||
endDateTime: _endDateTime!,
|
endDateTime: _endDateTime!,
|
||||||
price: double.tryParse(_priceController.text) ?? 0.0,
|
basePrice: double.tryParse(_priceController.text) ?? 0.0,
|
||||||
installationTime: int.tryParse(_installationController.text) ?? 0,
|
installationTime: int.tryParse(_installationController.text) ?? 0,
|
||||||
disassemblyTime: int.tryParse(_disassemblyController.text) ?? 0,
|
disassemblyTime: int.tryParse(_disassemblyController.text) ?? 0,
|
||||||
eventTypeId: '', // à adapter si tu veux gérer les types
|
eventTypeId: '', // à adapter si tu veux gérer les types
|
||||||
@ -483,3 +665,214 @@ class _EventAddDialogState extends State<EventAddDialog> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_FirestoreStatusButton> createState() => _FirestoreStatusButtonState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FirestoreStatusButtonState extends State<_FirestoreStatusButton> {
|
||||||
|
bool _loading = false;
|
||||||
|
|
||||||
|
Future<void> changerStatut(EventStatus nouveau) async {
|
||||||
|
if (widget.currentStatus == nouveau) return;
|
||||||
|
setState(() => _loading = true);
|
||||||
|
await widget.onStatusChanged(nouveau);
|
||||||
|
setState(() => _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) {
|
||||||
|
case EventStatus.waitingForApproval:
|
||||||
|
texte = "En Attente";
|
||||||
|
couleurFond = Colors.yellow.shade600;
|
||||||
|
enfants = [
|
||||||
|
_buildIconButton(Icons.close, Colors.red,
|
||||||
|
() => changerStatut(EventStatus.canceled)),
|
||||||
|
_buildLabel(texte, couleurFond),
|
||||||
|
_buildIconButton(Icons.check, Colors.green,
|
||||||
|
() => changerStatut(EventStatus.confirmed)),
|
||||||
|
];
|
||||||
|
break;
|
||||||
|
case EventStatus.confirmed:
|
||||||
|
texte = "Confirmé";
|
||||||
|
couleurFond = Colors.green;
|
||||||
|
enfants = [
|
||||||
|
_buildIconButton(Icons.close, Colors.red,
|
||||||
|
() => changerStatut(EventStatus.canceled)),
|
||||||
|
_buildIconButton(Icons.hourglass_empty, Colors.yellow.shade700,
|
||||||
|
() => changerStatut(EventStatus.waitingForApproval)),
|
||||||
|
_buildLabel(texte, couleurFond),
|
||||||
|
];
|
||||||
|
break;
|
||||||
|
case EventStatus.canceled:
|
||||||
|
texte = "Annulé";
|
||||||
|
couleurFond = Colors.red;
|
||||||
|
enfants = [
|
||||||
|
_buildLabel(texte, couleurFond),
|
||||||
|
_buildIconButton(Icons.hourglass_empty, Colors.yellow.shade700,
|
||||||
|
() => changerStatut(EventStatus.waitingForApproval)),
|
||||||
|
_buildIconButton(Icons.check, Colors.green,
|
||||||
|
() => changerStatut(EventStatus.confirmed)),
|
||||||
|
];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
padding: const EdgeInsets.all(2),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Colors.transparent,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: enfants,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildLabel(String texte, Color couleur) {
|
||||||
|
return AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 2),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: couleur,
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: AnimatedSwitcher(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
child: Text(
|
||||||
|
texte,
|
||||||
|
key: ValueKey(texte),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold, color: Colors.white, fontSize: 13),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildIconButton(
|
||||||
|
IconData icone, Color couleur, VoidCallback onPressed) {
|
||||||
|
return AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: couleur, width: 1.5),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: IconButton(
|
||||||
|
icon: Icon(icone, color: couleur, size: 16),
|
||||||
|
onPressed: _loading ? null : onPressed,
|
||||||
|
splashRadius: 16,
|
||||||
|
tooltip: 'Changer statut',
|
||||||
|
padding: const EdgeInsets.all(4),
|
||||||
|
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -213,6 +213,27 @@ class MonthView extends StatelessWidget {
|
|||||||
|
|
||||||
Widget _buildEventItem(
|
Widget _buildEventItem(
|
||||||
EventModel event, bool isSelected, DateTime currentDay) {
|
EventModel event, bool isSelected, DateTime currentDay) {
|
||||||
|
Color color;
|
||||||
|
Color textColor;
|
||||||
|
IconData icon;
|
||||||
|
switch (event.status) {
|
||||||
|
case EventStatus.confirmed:
|
||||||
|
color = Colors.green;
|
||||||
|
textColor = Colors.white;
|
||||||
|
icon = Icons.check;
|
||||||
|
break;
|
||||||
|
case EventStatus.canceled:
|
||||||
|
color = Colors.red;
|
||||||
|
textColor = Colors.white;
|
||||||
|
icon = Icons.close;
|
||||||
|
break;
|
||||||
|
case EventStatus.waitingForApproval:
|
||||||
|
default:
|
||||||
|
color = Colors.amber;
|
||||||
|
textColor = Colors.black;
|
||||||
|
icon = Icons.hourglass_empty;
|
||||||
|
break;
|
||||||
|
}
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
onDaySelected(currentDay, currentDay);
|
onDaySelected(currentDay, currentDay);
|
||||||
@ -222,33 +243,40 @@ class MonthView extends StatelessWidget {
|
|||||||
margin: const EdgeInsets.only(bottom: 2),
|
margin: const EdgeInsets.only(bottom: 2),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isSelected
|
color: isSelected ? color.withAlpha(220) : color.withOpacity(0.18),
|
||||||
? Colors.white.withAlpha(51)
|
|
||||||
: AppColors.rouge.withAlpha(26),
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Icon(icon, color: textColor, size: 16),
|
||||||
event.name,
|
const SizedBox(width: 4),
|
||||||
style: TextStyle(
|
Expanded(
|
||||||
fontSize: 12,
|
child: Column(
|
||||||
color: isSelected ? Colors.white : AppColors.rouge,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
fontWeight: FontWeight.bold,
|
children: [
|
||||||
|
Text(
|
||||||
|
event.name,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: textColor,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
if (CalendarUtils.isMultiDayEvent(event))
|
||||||
|
Text(
|
||||||
|
'Jour ${CalendarUtils.calculateDayNumber(event.startDateTime, currentDay)}/${CalendarUtils.calculateTotalDays(event)}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
color: textColor,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
),
|
||||||
if (CalendarUtils.isMultiDayEvent(event))
|
|
||||||
Text(
|
|
||||||
'Jour ${CalendarUtils.calculateDayNumber(event.startDateTime, event.startDateTime)}/${CalendarUtils.calculateTotalDays(event)}',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 10,
|
|
||||||
color: isSelected ? Colors.white : AppColors.rouge,
|
|
||||||
),
|
|
||||||
maxLines: 1,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -272,36 +272,43 @@ class WeekView extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.all(4),
|
padding: const EdgeInsets.all(4),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isSelected
|
color: isSelected
|
||||||
? AppColors.rouge.withAlpha(80)
|
? _getStatusColor(e.event.status).withAlpha(220)
|
||||||
: AppColors.rouge.withAlpha(26),
|
: _getStatusColor(e.event.status).withOpacity(0.18),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: AppColors.rouge,
|
color: _getStatusColor(e.event.status),
|
||||||
width: isSelected ? 3 : 1,
|
width: isSelected ? 3 : 1,
|
||||||
),
|
),
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Expanded(
|
||||||
e.event.name,
|
child: Column(
|
||||||
style: const TextStyle(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
color: AppColors.rouge,
|
children: [
|
||||||
fontSize: 12,
|
Text(
|
||||||
fontWeight: FontWeight.bold,
|
e.event.name,
|
||||||
|
style: TextStyle(
|
||||||
|
color: _getStatusTextColor(e.event.status),
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
if (CalendarUtils.isMultiDayEvent(e.event))
|
||||||
|
Text(
|
||||||
|
'Jour ${CalendarUtils.calculateDayNumber(e.event.startDateTime, weekStart.add(Duration(days: dayIdx)))}/${CalendarUtils.calculateTotalDays(e.event)}',
|
||||||
|
style: TextStyle(
|
||||||
|
color: _getStatusTextColor(e.event.status),
|
||||||
|
fontSize: 10,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
),
|
||||||
if (CalendarUtils.isMultiDayEvent(e.event))
|
|
||||||
Text(
|
|
||||||
'Jour ${CalendarUtils.calculateDayNumber(e.event.startDateTime, weekStart.add(Duration(days: dayIdx)))}/${CalendarUtils.calculateTotalDays(e.event)}',
|
|
||||||
style: const TextStyle(
|
|
||||||
color: AppColors.rouge,
|
|
||||||
fontSize: 10,
|
|
||||||
),
|
|
||||||
maxLines: 1,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -377,6 +384,41 @@ class WeekView extends StatelessWidget {
|
|||||||
bool _overlap(_PositionedEvent a, _PositionedEvent b) {
|
bool _overlap(_PositionedEvent a, _PositionedEvent b) {
|
||||||
return a.end.isAfter(b.start) && a.start.isBefore(b.end);
|
return a.end.isAfter(b.start) && a.start.isBefore(b.end);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Color _getStatusColor(EventStatus status) {
|
||||||
|
switch (status) {
|
||||||
|
case EventStatus.confirmed:
|
||||||
|
return Colors.green;
|
||||||
|
case EventStatus.canceled:
|
||||||
|
return Colors.red;
|
||||||
|
case EventStatus.waitingForApproval:
|
||||||
|
default:
|
||||||
|
return Colors.amber;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _getStatusTextColor(EventStatus status) {
|
||||||
|
switch (status) {
|
||||||
|
case EventStatus.confirmed:
|
||||||
|
case EventStatus.canceled:
|
||||||
|
return Colors.white;
|
||||||
|
case EventStatus.waitingForApproval:
|
||||||
|
default:
|
||||||
|
return Colors.black;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IconData _getStatusIcon(EventStatus status) {
|
||||||
|
switch (status) {
|
||||||
|
case EventStatus.confirmed:
|
||||||
|
return Icons.check;
|
||||||
|
case EventStatus.canceled:
|
||||||
|
return Icons.close;
|
||||||
|
case EventStatus.waitingForApproval:
|
||||||
|
default:
|
||||||
|
return Icons.hourglass_empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _PositionedEvent {
|
class _PositionedEvent {
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
|
|
||||||
class ProfilePictureWidget extends StatelessWidget {
|
class ProfilePictureWidget extends StatefulWidget {
|
||||||
final String? userId; // Modifié pour être nullable
|
final String? userId;
|
||||||
final double radius;
|
final double radius;
|
||||||
final String? defaultImageUrl;
|
final String? defaultImageUrl;
|
||||||
|
|
||||||
@ -13,28 +13,58 @@ class ProfilePictureWidget extends StatelessWidget {
|
|||||||
this.defaultImageUrl,
|
this.defaultImageUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ProfilePictureWidget> createState() => _ProfilePictureWidgetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ProfilePictureWidgetState extends State<ProfilePictureWidget> {
|
||||||
|
late Future<DocumentSnapshot?> _userFuture;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_userFuture = _getUserFuture();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(ProfilePictureWidget oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (oldWidget.userId != widget.userId) {
|
||||||
|
_userFuture = _getUserFuture();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<DocumentSnapshot?> _getUserFuture() {
|
||||||
|
if (widget.userId == null || widget.userId!.isEmpty) {
|
||||||
|
return Future.value(null);
|
||||||
|
}
|
||||||
|
return FirebaseFirestore.instance
|
||||||
|
.collection('users')
|
||||||
|
.doc(widget.userId)
|
||||||
|
.get();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// Vérifier si userId est null ou vide
|
if (widget.userId == null || widget.userId!.isEmpty) {
|
||||||
if (userId == null || userId!.isEmpty) {
|
return _buildDefaultAvatar(widget.radius, widget.defaultImageUrl);
|
||||||
return _buildDefaultAvatar(radius, defaultImageUrl);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return FutureBuilder<DocumentSnapshot>(
|
return FutureBuilder<DocumentSnapshot?>(
|
||||||
future: FirebaseFirestore.instance.collection('users').doc(userId).get(),
|
future: _userFuture,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||||
return _buildLoadingAvatar(radius);
|
return _buildLoadingAvatar(widget.radius);
|
||||||
} else if (snapshot.hasError) {
|
} else if (snapshot.hasError) {
|
||||||
print("Error loading profile: ${snapshot.error}");
|
print("Error loading profile: ${snapshot.error}");
|
||||||
return _buildDefaultAvatar(radius, defaultImageUrl);
|
return _buildDefaultAvatar(widget.radius, widget.defaultImageUrl);
|
||||||
} else if (snapshot.data != null && snapshot.data!.exists) {
|
} else if (snapshot.data != null && snapshot.data!.exists) {
|
||||||
final userData = snapshot.data!.data() as Map<String, dynamic>?;
|
final userData = snapshot.data!.data() as Map<String, dynamic>?;
|
||||||
final profilePhotoUrl = userData?['profilePhotoUrl'] as String?;
|
final profilePhotoUrl = userData?['profilePhotoUrl'] as String?;
|
||||||
|
|
||||||
if (profilePhotoUrl != null && profilePhotoUrl.isNotEmpty) {
|
if (profilePhotoUrl != null && profilePhotoUrl.isNotEmpty) {
|
||||||
return CircleAvatar(
|
return CircleAvatar(
|
||||||
radius: radius,
|
radius: widget.radius,
|
||||||
backgroundImage: NetworkImage(profilePhotoUrl),
|
backgroundImage: NetworkImage(profilePhotoUrl),
|
||||||
onBackgroundImageError: (e, stack) {
|
onBackgroundImageError: (e, stack) {
|
||||||
print("Error loading profile image: $e");
|
print("Error loading profile image: $e");
|
||||||
@ -42,7 +72,7 @@ class ProfilePictureWidget extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return _buildDefaultAvatar(radius, defaultImageUrl);
|
return _buildDefaultAvatar(widget.radius, widget.defaultImageUrl);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
260
em2rp/lib/views/widgets/inputs/dropzone_upload_widget.dart
Normal file
260
em2rp/lib/views/widgets/inputs/dropzone_upload_widget.dart
Normal file
@ -0,0 +1,260 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:firebase_storage/firebase_storage.dart';
|
||||||
|
import 'package:flutter_dropzone/flutter_dropzone.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
|
class DropzoneUploadWidget extends StatefulWidget {
|
||||||
|
final List<Map<String, String>> uploadedFiles;
|
||||||
|
final ValueChanged<List<Map<String, String>>> onFilesChanged;
|
||||||
|
final bool isLoading;
|
||||||
|
final String? error;
|
||||||
|
final String? success;
|
||||||
|
final double width;
|
||||||
|
final double height;
|
||||||
|
|
||||||
|
const DropzoneUploadWidget({
|
||||||
|
super.key,
|
||||||
|
required this.uploadedFiles,
|
||||||
|
required this.onFilesChanged,
|
||||||
|
this.isLoading = false,
|
||||||
|
this.error,
|
||||||
|
this.success,
|
||||||
|
this.width = 400,
|
||||||
|
this.height = 200,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<DropzoneUploadWidget> createState() => _DropzoneUploadWidgetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DropzoneUploadWidgetState extends State<DropzoneUploadWidget> {
|
||||||
|
DropzoneViewController? _dropzoneController;
|
||||||
|
bool _isDropzoneHighlighted = false;
|
||||||
|
bool _isLoading = false;
|
||||||
|
String? _error;
|
||||||
|
String? _success;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_isLoading = widget.isLoading;
|
||||||
|
_error = widget.error;
|
||||||
|
_success = widget.success;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleFiles(List<dynamic> files) async {
|
||||||
|
setState(() => _isLoading = true);
|
||||||
|
try {
|
||||||
|
List<Map<String, String>> newFiles = List.from(widget.uploadedFiles);
|
||||||
|
for (final file in files) {
|
||||||
|
final name = await _dropzoneController!.getFilename(file);
|
||||||
|
final bytes = await _dropzoneController!.getFileData(file);
|
||||||
|
if (bytes != null) {
|
||||||
|
final ref = FirebaseStorage.instance.ref().child(
|
||||||
|
'events/temp/${DateTime.now().millisecondsSinceEpoch}_$name');
|
||||||
|
final uploadTask = await ref.putData(bytes);
|
||||||
|
final url = await uploadTask.ref.getDownloadURL();
|
||||||
|
if (!newFiles.any((f) => f['name'] == name && f['url'] == url)) {
|
||||||
|
newFiles.add({'name': name, 'url': url});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
widget.onFilesChanged(newFiles);
|
||||||
|
setState(() {
|
||||||
|
_success = "Fichier(s) ajouté(s) !";
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
setState(() {
|
||||||
|
_error = 'Erreur lors de l\'upload : $e';
|
||||||
|
_success = null;
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
_isDropzoneHighlighted = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SizedBox(
|
||||||
|
width: widget.width,
|
||||||
|
height: widget.height,
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(
|
||||||
|
color: _isDropzoneHighlighted ? Colors.blue : Colors.grey,
|
||||||
|
width: 2,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
color: _isDropzoneHighlighted ? Colors.blue.withOpacity(0.1) : null,
|
||||||
|
),
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
DropzoneView(
|
||||||
|
onCreated: (controller) => _dropzoneController = controller,
|
||||||
|
onDropFiles: (files) async {
|
||||||
|
if (files == null) return;
|
||||||
|
await _handleFiles(files);
|
||||||
|
},
|
||||||
|
onHover: () => setState(() => _isDropzoneHighlighted = true),
|
||||||
|
onLeave: () => setState(() => _isDropzoneHighlighted = false),
|
||||||
|
),
|
||||||
|
Positioned.fill(
|
||||||
|
child: IgnorePointer(
|
||||||
|
child: widget.uploadedFiles.isEmpty
|
||||||
|
? Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.cloud_upload,
|
||||||
|
size: 48, color: Colors.grey),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
const Text(
|
||||||
|
'Glissez-déposez des fichiers ici ou cliquez sur "Ajouter"',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(color: Colors.grey, fontSize: 16),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (widget.uploadedFiles.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(8.0),
|
||||||
|
child: _buildFileListAndButton(),
|
||||||
|
),
|
||||||
|
if (widget.uploadedFiles.isEmpty)
|
||||||
|
Positioned(
|
||||||
|
bottom: 16,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
child: Center(
|
||||||
|
child: SizedBox(
|
||||||
|
width: 160,
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
icon: const Icon(Icons.attach_file, size: 18),
|
||||||
|
label: const Text('Ajouter'),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8, vertical: 8),
|
||||||
|
minimumSize: const Size(80, 36),
|
||||||
|
textStyle: const TextStyle(fontSize: 14),
|
||||||
|
),
|
||||||
|
onPressed: _isLoading
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
|
final files =
|
||||||
|
await _dropzoneController?.pickFiles();
|
||||||
|
if (files != null) {
|
||||||
|
await _handleFiles(files);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_isLoading)
|
||||||
|
const Positioned.fill(
|
||||||
|
child: ColoredBox(
|
||||||
|
color: Color.fromARGB(80, 255, 255, 255),
|
||||||
|
child: Center(
|
||||||
|
child: SizedBox(
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
child: CircularProgressIndicator()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_error != null)
|
||||||
|
Positioned(
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.only(top: 8.0),
|
||||||
|
child:
|
||||||
|
Text(_error!, style: const TextStyle(color: Colors.red)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_success != null)
|
||||||
|
Positioned(
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 32.0),
|
||||||
|
child: Text(_success!,
|
||||||
|
style: const TextStyle(color: Colors.green)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildFileListAndButton() {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
...widget.uploadedFiles.map((file) {
|
||||||
|
final fileName = file['name']!;
|
||||||
|
final ext = p.extension(fileName).toLowerCase();
|
||||||
|
IconData icon;
|
||||||
|
if ([".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"]
|
||||||
|
.contains(ext)) {
|
||||||
|
icon = Icons.image;
|
||||||
|
} else if (ext == ".pdf") {
|
||||||
|
icon = Icons.picture_as_pdf;
|
||||||
|
} else if ([".txt", ".md", ".csv", ".json", ".xml"].contains(ext)) {
|
||||||
|
icon = Icons.description;
|
||||||
|
} else {
|
||||||
|
icon = Icons.attach_file;
|
||||||
|
}
|
||||||
|
return ListTile(
|
||||||
|
leading: Icon(icon, color: Colors.blueGrey),
|
||||||
|
title: Text(fileName, overflow: TextOverflow.ellipsis),
|
||||||
|
trailing: IconButton(
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
onPressed: _isLoading
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
final newFiles =
|
||||||
|
List<Map<String, String>>.from(widget.uploadedFiles)
|
||||||
|
..remove(file);
|
||||||
|
widget.onFilesChanged(newFiles);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
dense: true,
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
SizedBox(
|
||||||
|
width: 160,
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
icon: const Icon(Icons.attach_file, size: 18),
|
||||||
|
label: const Text('Ajouter'),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||||
|
minimumSize: const Size(80, 36),
|
||||||
|
textStyle: const TextStyle(fontSize: 14),
|
||||||
|
),
|
||||||
|
onPressed: _isLoading
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
|
final files = await _dropzoneController?.pickFiles();
|
||||||
|
if (files != null) {
|
||||||
|
await _handleFiles(files);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
467
em2rp/lib/views/widgets/inputs/option_selector_widget.dart
Normal file
467
em2rp/lib/views/widgets/inputs/option_selector_widget.dart
Normal file
@ -0,0 +1,467 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
|
import 'package:em2rp/models/option_model.dart';
|
||||||
|
|
||||||
|
class OptionSelectorWidget extends StatefulWidget {
|
||||||
|
final String? eventType;
|
||||||
|
final List<Map<String, dynamic>> selectedOptions;
|
||||||
|
final ValueChanged<List<Map<String, dynamic>>> onChanged;
|
||||||
|
final void Function(String name)? onRemove;
|
||||||
|
final bool eventTypeRequired;
|
||||||
|
final bool isMobile;
|
||||||
|
|
||||||
|
const OptionSelectorWidget({
|
||||||
|
super.key,
|
||||||
|
required this.eventType,
|
||||||
|
required this.selectedOptions,
|
||||||
|
required this.onChanged,
|
||||||
|
this.onRemove,
|
||||||
|
this.eventTypeRequired = false,
|
||||||
|
this.isMobile = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<OptionSelectorWidget> createState() => _OptionSelectorWidgetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _OptionSelectorWidgetState extends State<OptionSelectorWidget> {
|
||||||
|
List<EventOption> _allOptions = [];
|
||||||
|
bool _loading = true;
|
||||||
|
String _search = '';
|
||||||
|
final List<String> _eventTypes = ['Bal', 'Mariage', 'Anniversaire'];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant OptionSelectorWidget oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (oldWidget.eventType != widget.eventType) {
|
||||||
|
_fetchOptions();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_fetchOptions();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _fetchOptions() async {
|
||||||
|
setState(() => _loading = true);
|
||||||
|
final snapshot =
|
||||||
|
await FirebaseFirestore.instance.collection('options').get();
|
||||||
|
final options = snapshot.docs
|
||||||
|
.map((doc) => EventOption.fromMap(doc.data(), doc.id))
|
||||||
|
.toList();
|
||||||
|
setState(() {
|
||||||
|
_allOptions = options;
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showOptionPicker() async {
|
||||||
|
final selected = await showDialog<Map<String, dynamic>>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => _OptionPickerDialog(
|
||||||
|
allOptions: _allOptions,
|
||||||
|
eventType: widget.eventType,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (selected != null) {
|
||||||
|
final newList = List<Map<String, dynamic>>.from(widget.selectedOptions)
|
||||||
|
..add(selected);
|
||||||
|
widget.onChanged(newList);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text('Options sélectionnées',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Column(
|
||||||
|
children: widget.selectedOptions
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Center(
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
label: const Text('Ajouter une option'),
|
||||||
|
onPressed:
|
||||||
|
_loading || widget.eventTypeRequired ? null : _showOptionPicker,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _OptionPickerDialog extends StatefulWidget {
|
||||||
|
final List<EventOption> allOptions;
|
||||||
|
final String? eventType;
|
||||||
|
const _OptionPickerDialog(
|
||||||
|
{required this.allOptions, required this.eventType});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_OptionPickerDialog> createState() => _OptionPickerDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _OptionPickerDialogState extends State<_OptionPickerDialog> {
|
||||||
|
String _search = '';
|
||||||
|
bool _creating = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final filtered = widget.allOptions.where((opt) {
|
||||||
|
if (widget.eventType == null) return false;
|
||||||
|
final matchesType =
|
||||||
|
opt.eventTypes.any((ref) => ref.id == widget.eventType);
|
||||||
|
final matchesSearch =
|
||||||
|
opt.name.toLowerCase().contains(_search.toLowerCase());
|
||||||
|
return matchesType && matchesSearch;
|
||||||
|
}).toList();
|
||||||
|
return Dialog(
|
||||||
|
child: SizedBox(
|
||||||
|
width: 400,
|
||||||
|
height: 500,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(12.0),
|
||||||
|
child: TextField(
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Rechercher une option',
|
||||||
|
prefixIcon: Icon(Icons.search),
|
||||||
|
),
|
||||||
|
onChanged: (v) => setState(() => _search = v),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: filtered.isEmpty
|
||||||
|
? const Center(
|
||||||
|
child: Text(
|
||||||
|
'Aucune option disponible pour ce type d\'événement.'))
|
||||||
|
: ListView.builder(
|
||||||
|
itemCount: filtered.length,
|
||||||
|
itemBuilder: (context, i) {
|
||||||
|
final opt = filtered[i];
|
||||||
|
return ListTile(
|
||||||
|
title: Text(opt.name),
|
||||||
|
subtitle: Text(opt.details +
|
||||||
|
'\nFourchette: ${opt.valMin}€ ~ ${opt.valMax}€'),
|
||||||
|
onTap: () async {
|
||||||
|
final min = opt.valMin;
|
||||||
|
final max = opt.valMax;
|
||||||
|
final defaultPrice =
|
||||||
|
((min + max) / 2).toStringAsFixed(2);
|
||||||
|
final price = await showDialog<double>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) {
|
||||||
|
final priceController =
|
||||||
|
TextEditingController(text: defaultPrice);
|
||||||
|
return AlertDialog(
|
||||||
|
title: Text('Prix pour ${opt.name}'),
|
||||||
|
content: TextField(
|
||||||
|
controller: priceController,
|
||||||
|
keyboardType:
|
||||||
|
const TextInputType.numberWithOptions(
|
||||||
|
decimal: true),
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Prix (€)'),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx),
|
||||||
|
child: const Text('Annuler'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
final price = double.tryParse(
|
||||||
|
priceController.text
|
||||||
|
.replaceAll(',', '.')) ??
|
||||||
|
0.0;
|
||||||
|
Navigator.pop(ctx, price);
|
||||||
|
},
|
||||||
|
child: const Text('Ajouter'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (price != null) {
|
||||||
|
Navigator.pop(context, {
|
||||||
|
'name': opt.name,
|
||||||
|
'price': price,
|
||||||
|
'compatibleTypes': opt.eventTypes
|
||||||
|
.map((ref) => ref.id)
|
||||||
|
.toList(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8.0, top: 4.0),
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () async {
|
||||||
|
setState(() => _creating = true);
|
||||||
|
final created = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => _CreateOptionDialog(),
|
||||||
|
);
|
||||||
|
setState(() => _creating = false);
|
||||||
|
if (created == true) {
|
||||||
|
Navigator.pop(context);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: _creating
|
||||||
|
? const Padding(
|
||||||
|
padding: EdgeInsets.all(8.0),
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
|
)
|
||||||
|
: const Text(
|
||||||
|
'Ajouter une nouvelle option',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.blue,
|
||||||
|
decoration: TextDecoration.underline,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CreateOptionDialog extends StatefulWidget {
|
||||||
|
@override
|
||||||
|
State<_CreateOptionDialog> createState() => _CreateOptionDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CreateOptionDialogState extends State<_CreateOptionDialog> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
final _nameController = TextEditingController();
|
||||||
|
final _detailsController = TextEditingController();
|
||||||
|
final _minPriceController = TextEditingController();
|
||||||
|
final _maxPriceController = TextEditingController();
|
||||||
|
List<String> _selectedTypes = [];
|
||||||
|
final List<String> _allTypes = ['Bal', 'Mariage', 'Anniversaire'];
|
||||||
|
String? _error;
|
||||||
|
bool _checkingName = false;
|
||||||
|
|
||||||
|
Future<bool> _isNameUnique(String name) async {
|
||||||
|
final snap = await FirebaseFirestore.instance
|
||||||
|
.collection('options')
|
||||||
|
.where('name', isEqualTo: name)
|
||||||
|
.get();
|
||||||
|
return snap.docs.isEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Créer une nouvelle option'),
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
TextFormField(
|
||||||
|
controller: _nameController,
|
||||||
|
decoration:
|
||||||
|
const InputDecoration(labelText: 'Nom de l\'option'),
|
||||||
|
validator: (v) =>
|
||||||
|
v == null || v.isEmpty ? 'Champ requis' : null,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextFormField(
|
||||||
|
controller: _detailsController,
|
||||||
|
decoration: const InputDecoration(labelText: 'Détails'),
|
||||||
|
maxLines: 2,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextFormField(
|
||||||
|
controller: _minPriceController,
|
||||||
|
decoration:
|
||||||
|
const InputDecoration(labelText: 'Prix min (€)'),
|
||||||
|
keyboardType:
|
||||||
|
const TextInputType.numberWithOptions(decimal: true),
|
||||||
|
validator: (v) =>
|
||||||
|
v == null || v.isEmpty ? 'Obligatoire' : null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: TextFormField(
|
||||||
|
controller: _maxPriceController,
|
||||||
|
decoration:
|
||||||
|
const InputDecoration(labelText: 'Prix max (€)'),
|
||||||
|
keyboardType:
|
||||||
|
const TextInputType.numberWithOptions(decimal: true),
|
||||||
|
validator: (v) =>
|
||||||
|
v == null || v.isEmpty ? 'Obligatoire' : null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text('Types d\'événement associés :'),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
children: _allTypes
|
||||||
|
.map((type) => FilterChip(
|
||||||
|
label: Text(type),
|
||||||
|
selected: _selectedTypes.contains(type),
|
||||||
|
onSelected: (selected) {
|
||||||
|
setState(() {
|
||||||
|
if (selected) {
|
||||||
|
_selectedTypes.add(type);
|
||||||
|
} else {
|
||||||
|
_selectedTypes.remove(type);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (_error != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8.0),
|
||||||
|
child:
|
||||||
|
Text(_error!, style: const TextStyle(color: Colors.red)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, false),
|
||||||
|
child: const Text('Annuler'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: _checkingName
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
|
if (!_formKey.currentState!.validate()) return;
|
||||||
|
if (_selectedTypes.isEmpty) {
|
||||||
|
setState(() =>
|
||||||
|
_error = 'Sélectionnez au moins un type d\'événement');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final min = double.tryParse(
|
||||||
|
_minPriceController.text.replaceAll(',', '.'));
|
||||||
|
final max = double.tryParse(
|
||||||
|
_maxPriceController.text.replaceAll(',', '.'));
|
||||||
|
if (min == null || max == null) {
|
||||||
|
setState(
|
||||||
|
() => _error = 'Prix min et max doivent être valides');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final name = _nameController.text.trim();
|
||||||
|
setState(() => _checkingName = true);
|
||||||
|
final unique = await _isNameUnique(name);
|
||||||
|
setState(() => _checkingName = false);
|
||||||
|
if (!unique) {
|
||||||
|
setState(
|
||||||
|
() => _error = 'Ce nom d\'option est déjà utilisé.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final eventTypeRefs = _selectedTypes
|
||||||
|
.map((type) => FirebaseFirestore.instance
|
||||||
|
.collection('eventTypes')
|
||||||
|
.doc(type))
|
||||||
|
.toList();
|
||||||
|
try {
|
||||||
|
await FirebaseFirestore.instance.collection('options').add({
|
||||||
|
'name': name,
|
||||||
|
'details': _detailsController.text.trim(),
|
||||||
|
'valMin': min,
|
||||||
|
'valMax': max,
|
||||||
|
'eventTypes': eventTypeRefs,
|
||||||
|
});
|
||||||
|
Navigator.pop(context, true);
|
||||||
|
} catch (e) {
|
||||||
|
setState(() => _error = 'Erreur lors de la création : $e');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: _checkingName
|
||||||
|
? const SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2))
|
||||||
|
: const Text('Créer'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:em2rp/models/user_model.dart';
|
import 'package:em2rp/models/user_model.dart';
|
||||||
import 'package:em2rp/utils/colors.dart';
|
import 'package:em2rp/utils/colors.dart';
|
||||||
|
|
||||||
class UserCard extends StatelessWidget {
|
class UserCard extends StatefulWidget {
|
||||||
final UserModel user;
|
final UserModel user;
|
||||||
final VoidCallback onEdit;
|
final VoidCallback onEdit;
|
||||||
final VoidCallback onDelete;
|
final VoidCallback onDelete;
|
||||||
@ -16,6 +16,66 @@ class UserCard extends StatelessWidget {
|
|||||||
required this.onDelete,
|
required this.onDelete,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<UserCard> createState() => _UserCardState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _UserCardState extends State<UserCard> {
|
||||||
|
ImageProvider? _profileImage;
|
||||||
|
String? _lastUrl;
|
||||||
|
bool _isLoadingImage = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(UserCard oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (oldWidget.user.profilePhotoUrl != widget.user.profilePhotoUrl) {
|
||||||
|
_loadProfileImage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadProfileImage();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _loadProfileImage() {
|
||||||
|
final url = widget.user.profilePhotoUrl;
|
||||||
|
if (url.isNotEmpty) {
|
||||||
|
setState(() {
|
||||||
|
_isLoadingImage = true;
|
||||||
|
_lastUrl = url;
|
||||||
|
});
|
||||||
|
final image = NetworkImage(url);
|
||||||
|
image.resolve(const ImageConfiguration()).addListener(
|
||||||
|
ImageStreamListener(
|
||||||
|
(info, _) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_profileImage = image;
|
||||||
|
_isLoadingImage = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error, stack) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_profileImage = null;
|
||||||
|
_isLoadingImage = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setState(() {
|
||||||
|
_profileImage = null;
|
||||||
|
_isLoadingImage = false;
|
||||||
|
_lastUrl = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final width = MediaQuery.of(context).size.width;
|
final width = MediaQuery.of(context).size.width;
|
||||||
@ -27,7 +87,7 @@ class UserCard extends StatelessWidget {
|
|||||||
elevation: 3,
|
elevation: 3,
|
||||||
child: Container(
|
child: Container(
|
||||||
constraints: BoxConstraints(
|
constraints: BoxConstraints(
|
||||||
maxWidth: isMobile ? double.infinity : _desktopMaxWidth,
|
maxWidth: isMobile ? double.infinity : UserCard._desktopMaxWidth,
|
||||||
),
|
),
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
child:
|
child:
|
||||||
@ -47,13 +107,13 @@ class UserCard extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
"${user.firstName} ${user.lastName}",
|
"${widget.user.firstName} ${widget.user.lastName}",
|
||||||
style: Theme.of(context).textTheme.titleSmall,
|
style: Theme.of(context).textTheme.titleSmall,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
user.email,
|
widget.user.email,
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
@ -65,7 +125,7 @@ class UserCard extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.edit, size: 20),
|
icon: const Icon(Icons.edit, size: 20),
|
||||||
onPressed: onEdit,
|
onPressed: widget.onEdit,
|
||||||
color: AppColors.rouge,
|
color: AppColors.rouge,
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
constraints: const BoxConstraints(
|
constraints: const BoxConstraints(
|
||||||
@ -75,7 +135,7 @@ class UserCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.delete, size: 20),
|
icon: const Icon(Icons.delete, size: 20),
|
||||||
onPressed: onDelete,
|
onPressed: widget.onDelete,
|
||||||
color: AppColors.gris,
|
color: AppColors.gris,
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
constraints: const BoxConstraints(
|
constraints: const BoxConstraints(
|
||||||
@ -106,22 +166,22 @@ class UserCard extends StatelessWidget {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
"${user.firstName} ${user.lastName}",
|
"${widget.user.firstName} ${widget.user.lastName}",
|
||||||
style: Theme.of(context).textTheme.titleSmall,
|
style: Theme.of(context).textTheme.titleSmall,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
user.email,
|
widget.user.email,
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
if (user.role.isNotEmpty) ...[
|
if (widget.user.role.isNotEmpty) ...[
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
user.role,
|
widget.user.role,
|
||||||
style: Theme.of(context).textTheme.bodySmall!.copyWith(
|
style: Theme.of(context).textTheme.bodySmall!.copyWith(
|
||||||
color: AppColors.gris,
|
color: AppColors.gris,
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
@ -143,7 +203,7 @@ class UserCard extends StatelessWidget {
|
|||||||
_buildButton(
|
_buildButton(
|
||||||
icon: Icons.edit,
|
icon: Icons.edit,
|
||||||
label: "Modifier",
|
label: "Modifier",
|
||||||
onPressed: onEdit,
|
onPressed: widget.onEdit,
|
||||||
color: AppColors.rouge,
|
color: AppColors.rouge,
|
||||||
isNarrow: true,
|
isNarrow: true,
|
||||||
),
|
),
|
||||||
@ -151,7 +211,7 @@ class UserCard extends StatelessWidget {
|
|||||||
_buildButton(
|
_buildButton(
|
||||||
icon: Icons.delete,
|
icon: Icons.delete,
|
||||||
label: "Supprimer",
|
label: "Supprimer",
|
||||||
onPressed: onDelete,
|
onPressed: widget.onDelete,
|
||||||
color: AppColors.gris,
|
color: AppColors.gris,
|
||||||
isNarrow: true,
|
isNarrow: true,
|
||||||
),
|
),
|
||||||
@ -163,7 +223,7 @@ class UserCard extends StatelessWidget {
|
|||||||
_buildButton(
|
_buildButton(
|
||||||
icon: Icons.edit,
|
icon: Icons.edit,
|
||||||
label: "Modifier",
|
label: "Modifier",
|
||||||
onPressed: onEdit,
|
onPressed: widget.onEdit,
|
||||||
color: AppColors.rouge,
|
color: AppColors.rouge,
|
||||||
isNarrow: false,
|
isNarrow: false,
|
||||||
),
|
),
|
||||||
@ -171,7 +231,7 @@ class UserCard extends StatelessWidget {
|
|||||||
_buildButton(
|
_buildButton(
|
||||||
icon: Icons.delete,
|
icon: Icons.delete,
|
||||||
label: "Supprimer",
|
label: "Supprimer",
|
||||||
onPressed: onDelete,
|
onPressed: widget.onDelete,
|
||||||
color: AppColors.gris,
|
color: AppColors.gris,
|
||||||
isNarrow: false,
|
isNarrow: false,
|
||||||
),
|
),
|
||||||
@ -218,13 +278,22 @@ class UserCard extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _profileAvatar(double size) {
|
Widget _profileAvatar(double size) {
|
||||||
|
if (_isLoadingImage && widget.user.profilePhotoUrl.isNotEmpty) {
|
||||||
|
return CircleAvatar(
|
||||||
|
radius: size / 2,
|
||||||
|
backgroundColor: Colors.grey[300],
|
||||||
|
child: SizedBox(
|
||||||
|
width: size * 0.5,
|
||||||
|
height: size * 0.5,
|
||||||
|
child: const CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
return CircleAvatar(
|
return CircleAvatar(
|
||||||
radius: size / 2,
|
radius: size / 2,
|
||||||
backgroundImage: user.profilePhotoUrl.isNotEmpty
|
backgroundImage: _profileImage,
|
||||||
? NetworkImage(user.profilePhotoUrl)
|
|
||||||
: null,
|
|
||||||
backgroundColor: Colors.grey[200],
|
backgroundColor: Colors.grey[200],
|
||||||
child: user.profilePhotoUrl.isEmpty
|
child: (widget.user.profilePhotoUrl.isEmpty || _profileImage == null)
|
||||||
? Icon(Icons.person, size: size * 0.6, color: AppColors.noir)
|
? Icon(Icons.person, size: size * 0.6, color: AppColors.noir)
|
||||||
: null,
|
: null,
|
||||||
);
|
);
|
||||||
|
@ -0,0 +1,219 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:em2rp/models/user_model.dart';
|
||||||
|
import 'package:em2rp/views/widgets/image/profile_picture.dart';
|
||||||
|
|
||||||
|
class UserMultiSelectWidget extends StatelessWidget {
|
||||||
|
final List<UserModel> allUsers;
|
||||||
|
final List<String> selectedUserIds;
|
||||||
|
final ValueChanged<List<String>> onChanged;
|
||||||
|
final bool isLoading;
|
||||||
|
|
||||||
|
const UserMultiSelectWidget({
|
||||||
|
super.key,
|
||||||
|
required this.allUsers,
|
||||||
|
required this.selectedUserIds,
|
||||||
|
required this.onChanged,
|
||||||
|
this.isLoading = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (isLoading) {
|
||||||
|
return const Center(
|
||||||
|
child:
|
||||||
|
SizedBox(width: 32, height: 32, child: CircularProgressIndicator()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return _UserMultiSelect(
|
||||||
|
allUsers: allUsers,
|
||||||
|
selectedUserIds: selectedUserIds,
|
||||||
|
onChanged: onChanged,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _UserMultiSelect extends StatefulWidget {
|
||||||
|
final List<UserModel> allUsers;
|
||||||
|
final List<String> selectedUserIds;
|
||||||
|
final ValueChanged<List<String>> onChanged;
|
||||||
|
|
||||||
|
const _UserMultiSelect({
|
||||||
|
super.key,
|
||||||
|
required this.allUsers,
|
||||||
|
required this.selectedUserIds,
|
||||||
|
required this.onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_UserMultiSelect> createState() => _UserMultiSelectState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _UserMultiSelectState extends State<_UserMultiSelect> {
|
||||||
|
void _openUserPicker() async {
|
||||||
|
final result = await showDialog<List<String>>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => _UserPickerDialog(
|
||||||
|
allUsers: widget.allUsers,
|
||||||
|
initiallySelected: widget.selectedUserIds,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (result != null) {
|
||||||
|
widget.onChanged(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final selectedUsers = widget.allUsers
|
||||||
|
.where((u) => widget.selectedUserIds.contains(u.uid))
|
||||||
|
.toList();
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
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(
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
label: const Text('Ajouter'),
|
||||||
|
onPressed: _openUserPicker,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _UserPickerDialog extends StatefulWidget {
|
||||||
|
final List<UserModel> allUsers;
|
||||||
|
final List<String> initiallySelected;
|
||||||
|
const _UserPickerDialog({
|
||||||
|
required this.allUsers,
|
||||||
|
required this.initiallySelected,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_UserPickerDialog> createState() => _UserPickerDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _UserPickerDialogState extends State<_UserPickerDialog> {
|
||||||
|
String _search = '';
|
||||||
|
late List<String> _selected;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_selected = List<String>.from(widget.initiallySelected);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final filteredUsers = widget.allUsers.where((u) {
|
||||||
|
final query = _search.toLowerCase();
|
||||||
|
return ('${u.firstName} ${u.lastName}').toLowerCase().contains(query);
|
||||||
|
}).toList();
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Ajouter du personnel'),
|
||||||
|
content: SizedBox(
|
||||||
|
width: 400,
|
||||||
|
height: 400,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Rechercher',
|
||||||
|
prefixIcon: Icon(Icons.search),
|
||||||
|
),
|
||||||
|
onChanged: (v) => setState(() => _search = v),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Expanded(
|
||||||
|
child: filteredUsers.isEmpty
|
||||||
|
? const Center(child: Text('Aucun utilisateur trouvé'))
|
||||||
|
: ListView.builder(
|
||||||
|
itemCount: filteredUsers.length,
|
||||||
|
itemBuilder: (context, i) {
|
||||||
|
final user = filteredUsers[i];
|
||||||
|
final isChecked = _selected.contains(user.uid);
|
||||||
|
return CheckboxListTile(
|
||||||
|
value: isChecked,
|
||||||
|
onChanged: (checked) {
|
||||||
|
setState(() {
|
||||||
|
if (checked == true) {
|
||||||
|
_selected.add(user.uid);
|
||||||
|
} else {
|
||||||
|
_selected.remove(user.uid);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
title: Text('${user.firstName} ${user.lastName}'),
|
||||||
|
subtitle: Text(user.email),
|
||||||
|
secondary: ProfilePictureWidget(
|
||||||
|
userId: user.uid, radius: 20),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Annuler'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => Navigator.pop(context, _selected),
|
||||||
|
child: const Text('Ajouter'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -22,19 +22,19 @@ dependencies:
|
|||||||
table_calendar: ^3.0.9
|
table_calendar: ^3.0.9
|
||||||
intl: ^0.19.0
|
intl: ^0.19.0
|
||||||
google_maps_flutter: ^2.5.0
|
google_maps_flutter: ^2.5.0
|
||||||
permission_handler: ^11.1.0
|
permission_handler: ^12.0.0+1
|
||||||
geolocator: ^10.1.0
|
geolocator: ^14.0.1
|
||||||
flutter_map: ^6.1.0
|
flutter_map: ^8.1.1
|
||||||
latlong2: ^0.9.0
|
latlong2: ^0.9.0
|
||||||
flutter_launcher_icons: ^0.13.1
|
flutter_launcher_icons: ^0.14.3
|
||||||
flutter_native_splash: ^2.3.9
|
flutter_native_splash: ^2.3.9
|
||||||
url_launcher: ^6.2.2
|
url_launcher: ^6.2.2
|
||||||
share_plus: ^7.2.1
|
share_plus: ^11.0.0
|
||||||
path_provider: ^2.1.2
|
path_provider: ^2.1.2
|
||||||
pdf: ^3.10.7
|
pdf: ^3.10.7
|
||||||
printing: ^5.11.1
|
printing: ^5.11.1
|
||||||
flutter_local_notifications: ^16.3.0
|
flutter_local_notifications: ^19.2.1
|
||||||
timezone: ^0.9.2
|
timezone: ^0.10.1
|
||||||
flutter_secure_storage: ^9.0.0
|
flutter_secure_storage: ^9.0.0
|
||||||
http: ^1.1.2
|
http: ^1.1.2
|
||||||
flutter_dotenv: ^5.1.0
|
flutter_dotenv: ^5.1.0
|
||||||
@ -43,15 +43,15 @@ dependencies:
|
|||||||
cached_network_image: ^3.3.1
|
cached_network_image: ^3.3.1
|
||||||
flutter_staggered_grid_view: ^0.7.0
|
flutter_staggered_grid_view: ^0.7.0
|
||||||
shimmer: ^3.0.0
|
shimmer: ^3.0.0
|
||||||
flutter_slidable: ^3.0.1
|
flutter_slidable: ^4.0.0
|
||||||
flutter_datetime_picker: ^1.5.1
|
flutter_datetime_picker: ^1.5.1
|
||||||
flutter_colorpicker: ^1.0.3
|
flutter_colorpicker: ^1.0.3
|
||||||
flutter_rating_bar: ^4.0.1
|
flutter_rating_bar: ^4.0.1
|
||||||
flutter_chat_ui: ^1.6.10
|
flutter_chat_ui: ^2.3.1
|
||||||
flutter_chat_types: ^3.6.2
|
flutter_chat_types: ^3.6.2
|
||||||
uuid: ^4.2.2
|
uuid: ^4.2.2
|
||||||
file_picker: ^6.1.1
|
file_picker: ^10.1.9
|
||||||
flutter_dropzone: ^3.0.6
|
flutter_dropzone: ^4.2.1
|
||||||
flutter_localizations:
|
flutter_localizations:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
|
||||||
|
@ -31,6 +31,17 @@
|
|||||||
|
|
||||||
<title>em2rp</title>
|
<title>em2rp</title>
|
||||||
<link rel="manifest" href="manifest.json">
|
<link rel="manifest" href="manifest.json">
|
||||||
|
<style>
|
||||||
|
html, body, #flt-glass-pane {
|
||||||
|
user-select: text !important;
|
||||||
|
-webkit-user-select: text !important;
|
||||||
|
-moz-user-select: text !important;
|
||||||
|
-ms-user-select: text !important;
|
||||||
|
}
|
||||||
|
.dropzone-view, .dropzone-view * {
|
||||||
|
pointer-events: auto !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<script src="flutter_bootstrap.js" async></script>
|
<script src="flutter_bootstrap.js" async></script>
|
||||||
|
Reference in New Issue
Block a user