split et refacto de event_details.dart

This commit is contained in:
ElPoyo
2025-10-15 14:09:44 +02:00
parent 4128ddc34a
commit f10a608801
15 changed files with 820 additions and 604 deletions

View File

@@ -1,3 +1,4 @@
description: This file stores settings for Dart & Flutter DevTools. description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions: extensions:
- provider: true

View File

@@ -1,4 +1,3 @@
import 'package:cloud_firestore/cloud_firestore.dart';
class EventOption { class EventOption {
final String id; final String id;

View File

@@ -58,7 +58,7 @@ class EventProvider with ChangeNotifier {
}); });
if (isInWorkforce) { if (isInWorkforce) {
print('Event ${event.name} includes user ${userId}'); print('Event ${event.name} includes user $userId');
} }
return isInWorkforce; return isInWorkforce;

View File

@@ -7,7 +7,6 @@ import 'package:em2rp/views/widgets/event_form/event_details_section.dart';
import 'package:em2rp/views/widgets/event_form/event_staff_and_documents_section.dart'; import 'package:em2rp/views/widgets/event_form/event_staff_and_documents_section.dart';
import 'package:em2rp/views/widgets/event_form/event_form_actions.dart'; import 'package:em2rp/views/widgets/event_form/event_form_actions.dart';
import 'package:em2rp/views/widgets/inputs/option_selector_widget.dart'; import 'package:em2rp/views/widgets/inputs/option_selector_widget.dart';
import 'package:flutter/foundation.dart';
class EventAddEditPage extends StatefulWidget { class EventAddEditPage extends StatefulWidget {
final EventModel? event; final EventModel? event;

View File

@@ -1,19 +1,16 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:em2rp/models/event_model.dart'; import 'package:em2rp/models/event_model.dart';
import 'package:em2rp/utils/colors.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:em2rp/providers/local_user_provider.dart'; import 'package:em2rp/providers/local_user_provider.dart';
import 'package:em2rp/providers/event_provider.dart'; import 'package:em2rp/providers/event_provider.dart';
import 'package:latlong2/latlong.dart'; import 'package:em2rp/views/widgets/calendar_widgets/event_details_components/event_details_navigation.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:em2rp/views/widgets/calendar_widgets/event_details_components/event_details_header.dart';
import 'package:path/path.dart' as p; import 'package:em2rp/views/widgets/calendar_widgets/event_details_components/event_status_button.dart';
import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:em2rp/views/widgets/calendar_widgets/event_details_components/event_details_info.dart';
import 'package:em2rp/views/widgets/user_management/user_card.dart'; import 'package:em2rp/views/widgets/calendar_widgets/event_details_components/event_details_description.dart';
import 'package:em2rp/models/user_model.dart'; import 'package:em2rp/views/widgets/calendar_widgets/event_details_components/event_details_documents.dart';
import 'package:em2rp/views/widgets/user_management/user_multi_select_widget.dart'; import 'package:em2rp/views/widgets/calendar_widgets/event_details_components/event_details_equipe.dart';
import 'package:em2rp/views/event_add_page.dart';
import 'package:em2rp/views/widgets/event_form/event_options_display_widget.dart';
class EventDetails extends StatelessWidget { class EventDetails extends StatelessWidget {
final EventModel event; final EventModel event;
@@ -31,15 +28,11 @@ class EventDetails extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final dateFormat = DateFormat('dd/MM/yyyy HH:mm');
final currencyFormat = NumberFormat.currency(locale: 'fr_FR', symbol: '');
final fullDateFormat = DateFormat('EEEE d MMMM y', 'fr_FR');
// Trie les événements par date de début // Trie les événements par date de début
final sortedEvents = List<EventModel>.from(events) final sortedEvents = List<EventModel>.from(events)
..sort((a, b) => a.startDateTime.compareTo(b.startDateTime)); ..sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
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 canViewPrices = localUserProvider.hasPermission('view_event_prices'); final canViewPrices = localUserProvider.hasPermission('view_event_prices');
return Card( return Card(
@@ -49,121 +42,22 @@ class EventDetails extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( EventDetailsNavigation(
mainAxisAlignment: MainAxisAlignment.spaceBetween, sortedEvents: sortedEvents,
children: [ currentIndex: currentIndex,
IconButton( selectedDate: selectedDate,
onPressed: currentIndex > 0 onSelectEvent: onSelectEvent,
? () {
final prevEvent = sortedEvents[currentIndex - 1];
onSelectEvent(prevEvent, prevEvent.startDateTime);
}
: null,
icon: const Icon(Icons.arrow_back),
color: AppColors.rouge,
),
if (selectedDate != null)
Expanded(
child: Center(
child: Text(
fullDateFormat.format(selectedDate!),
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: AppColors.rouge,
fontWeight: FontWeight.bold,
),
),
),
),
IconButton(
onPressed: currentIndex < sortedEvents.length - 1
? () {
final nextEvent = sortedEvents[currentIndex + 1];
onSelectEvent(nextEvent, nextEvent.startDateTime);
}
: null,
icon: const Icon(Icons.arrow_forward),
color: AppColors.rouge,
),
],
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Row( EventDetailsHeader(event: event),
//Titre de l'événement
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SelectableText(
event.name,
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
color: AppColors.noir,
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 12),
_buildStatusIcon(event.status),
],
),
const SizedBox(height: 4),
//Type d'événement
Text(
event.eventTypeId,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: AppColors.rouge,
),
),
],
),
),
// Statut de l'événement
const SizedBox(width: 8),
Spacer(),
if (Provider.of<LocalUserProvider>(context, listen: false)
.hasPermission('edit_event'))
IconButton(
icon: const Icon(Icons.edit, color: AppColors.rouge),
tooltip: 'Modifier',
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => EventAddEditPage(event: event),
),
);
},
),
],
),
if (Provider.of<LocalUserProvider>(context, listen: false) if (Provider.of<LocalUserProvider>(context, listen: false)
.hasPermission('change_event_status')) .hasPermission('change_event_status'))
Padding( Padding(
padding: const EdgeInsets.symmetric(vertical: 12.0), padding: const EdgeInsets.symmetric(vertical: 12.0),
child: _FirestoreStatusButton( child: EventStatusButton(
eventId: event.id, event: event,
currentStatus: event.status, selectedDate: selectedDate,
onStatusChanged: (newStatus) async { onSelectEvent: onSelectEvent,
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), const SizedBox(height: 16),
@@ -172,214 +66,15 @@ class EventDetails extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_buildInfoRow( EventDetailsInfo(
context, event: event,
Icons.calendar_today, canViewPrices: canViewPrices,
'Horaire de début',
dateFormat.format(event.startDateTime),
),
_buildInfoRow(
context,
Icons.calendar_today,
'Horaire de fin',
dateFormat.format(event.endDateTime),
),
if (canViewPrices)
_buildInfoRow(
context,
Icons.euro,
'Prix de base',
currencyFormat.format(event.basePrice),
),
if (event.options.isNotEmpty) ...[
EventOptionsDisplayWidget(
optionsData: event.options,
canViewPrices: canViewPrices,
showPriceCalculation: false, // On affiche le total séparément
),
],
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',
),
// Sous-titre: Horaire d'arrivée prévisionnelle (début - installation)
Builder(
builder: (context) {
final arrival = event.startDateTime.subtract(Duration(hours: event.installationTime));
return Padding(
padding: const EdgeInsets.only(left: 36.0, bottom: 4.0),
child: Text(
'Horaire d\'arrivée prévisionnel : ${DateFormat('dd/MM/yyyy HH:mm').format(arrival)}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey[700]),
),
);
},
),
_buildInfoRow(
context,
Icons.construction,
'Temps de démontage',
'${event.disassemblyTime} heures',
),
// Sous-titre: Horaire de départ prévu (fin + démontage)
Builder(
builder: (context) {
final departure = event.endDateTime.add(Duration(hours: event.disassemblyTime));
return Padding(
padding: const EdgeInsets.only(left: 36.0, bottom: 4.0),
child: Text(
'Horaire de départ prévu : ${DateFormat('dd/MM/yyyy HH:mm').format(departure)}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey[700]),
),
);
},
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( EventDetailsDescription(event: event),
'Description', EventDetailsDocuments(documents: event.documents),
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), const SizedBox(height: 16),
Text( EventDetailsEquipe(workforce: event.workforce),
'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),
],
], ],
), ),
), ),
@@ -389,64 +84,9 @@ class EventDetails extends StatelessWidget {
), ),
); );
} }
Widget _buildInfoRow(
BuildContext context,
IconData icon,
String label,
String value,
) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
Icon(icon, color: AppColors.rouge),
const SizedBox(width: 8),
Text(
'$label : ',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: AppColors.noir,
fontWeight: FontWeight.bold,
),
),
Text(
value,
style: Theme.of(context).textTheme.titleMedium,
),
],
),
);
}
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),
);
}
} }
// La classe EventAddDialog reste inchangée car elle n'est pas liée aux détails d'événement
class EventAddDialog extends StatefulWidget { class EventAddDialog extends StatefulWidget {
const EventAddDialog({super.key}); const EventAddDialog({super.key});
@@ -486,7 +126,9 @@ class _EventAddDialogState extends State<EventAddDialog> {
Future<void> _submit() async { Future<void> _submit() async {
if (!_formKey.currentState!.validate() || if (!_formKey.currentState!.validate() ||
_startDateTime == null || _startDateTime == null ||
_endDateTime == null) return; _endDateTime == null) {
return;
}
setState(() { setState(() {
_isLoading = true; _isLoading = true;
_error = null; _error = null;
@@ -685,214 +327,3 @@ 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;
}
}

View File

@@ -0,0 +1,54 @@
import 'package:flutter/material.dart';
import 'package:em2rp/models/event_model.dart';
import 'package:em2rp/utils/colors.dart';
class EventDetailsDescription extends StatelessWidget {
final EventModel event;
const EventDetailsDescription({
super.key,
required this.event,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
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,
),
],
],
);
}
}

View File

@@ -0,0 +1,99 @@
import 'package:flutter/material.dart';
import 'package:em2rp/utils/colors.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:path/path.dart' as p;
class EventDetailsDocuments extends StatelessWidget {
final List<Map<String, dynamic>> documents;
const EventDetailsDocuments({
super.key,
required this.documents,
});
@override
Widget build(BuildContext context) {
if (documents.isEmpty) {
return const SizedBox.shrink();
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
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: 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(),
),
],
);
}
}

View File

@@ -0,0 +1,137 @@
import 'package:flutter/material.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:em2rp/models/user_model.dart';
import 'package:em2rp/views/widgets/user_management/user_multi_select_widget.dart';
class EventDetailsEquipe extends StatelessWidget {
final List workforce;
const EventDetailsEquipe({
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 Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Equipe',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: Colors.black,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Center(child: CircularProgressIndicator()),
),
],
);
}
if (snapshot.hasError) {
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),
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é ou erreur de chargement.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.orange[700],
),
),
if (users.isNotEmpty)
UserChipsList(
users: users,
showRemove: false,
),
],
);
},
);
}
Future<List<UserModel>> _fetchUsers() async {
final firestore = FirebaseFirestore.instance;
List<UserModel> users = [];
for (int i = 0; i < workforce.length; i++) {
final ref = workforce[i];
try {
if (ref is DocumentReference) {
final doc = await firestore.doc(ref.path).get();
if (doc.exists) {
final userData = doc.data() as Map<String, dynamic>;
users.add(UserModel.fromMap(userData, doc.id));
}
}
} catch (e) {
// Log silencieux des erreurs individuelles
debugPrint('Error fetching user $i: $e');
}
}
return users;
}
}

View File

@@ -0,0 +1,92 @@
import 'package:flutter/material.dart';
import 'package:em2rp/models/event_model.dart';
import 'package:em2rp/utils/colors.dart';
import 'package:provider/provider.dart';
import 'package:em2rp/providers/local_user_provider.dart';
import 'package:em2rp/views/event_add_page.dart';
class EventDetailsHeader extends StatelessWidget {
final EventModel event;
const EventDetailsHeader({
super.key,
required this.event,
});
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
constraints: const BoxConstraints(maxHeight: 80),
child: SelectableText(
event.name,
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
color: AppColors.noir,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(height: 4),
Text(
event.eventTypeId,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: AppColors.rouge,
),
),
],
),
),
const SizedBox(width: 12),
_buildStatusIcon(event.status),
if (Provider.of<LocalUserProvider>(context, listen: false)
.hasPermission('edit_event')) ...[
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.edit, color: AppColors.rouge),
tooltip: 'Modifier',
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => EventAddEditPage(event: event),
),
);
},
),
],
],
);
}
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:
color = Colors.amber;
icon = Icons.hourglass_empty;
tooltip = 'En attente de validation';
break;
}
return Tooltip(
message: tooltip,
child: Icon(icon, color: color, size: 28),
);
}
}

View File

@@ -0,0 +1,162 @@
import 'package:flutter/material.dart';
import 'package:em2rp/models/event_model.dart';
import 'package:em2rp/utils/colors.dart';
import 'package:intl/intl.dart';
import 'package:em2rp/views/widgets/event_form/event_options_display_widget.dart';
class EventDetailsInfo extends StatelessWidget {
final EventModel event;
final bool canViewPrices;
const EventDetailsInfo({
super.key,
required this.event,
required this.canViewPrices,
});
@override
Widget build(BuildContext context) {
final dateFormat = DateFormat('dd/MM/yyyy HH:mm');
final currencyFormat = NumberFormat.currency(locale: 'fr_FR', symbol: '');
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildInfoRow(
context,
Icons.calendar_today,
'Horaire de début',
dateFormat.format(event.startDateTime),
),
_buildInfoRow(
context,
Icons.calendar_today,
'Horaire de fin',
dateFormat.format(event.endDateTime),
),
if (canViewPrices)
_buildInfoRow(
context,
Icons.euro,
'Prix de base',
currencyFormat.format(event.basePrice),
),
if (event.options.isNotEmpty) ...[
EventOptionsDisplayWidget(
optionsData: event.options,
canViewPrices: canViewPrices,
showPriceCalculation: false,
),
],
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',
),
Builder(
builder: (context) {
final arrival = event.startDateTime.subtract(
Duration(hours: event.installationTime),
);
return Padding(
padding: const EdgeInsets.only(left: 36.0, bottom: 4.0),
child: Text(
'Horaire d\'arrivée prévisionnel : ${dateFormat.format(arrival)}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey[700],
),
),
);
},
),
_buildInfoRow(
context,
Icons.construction,
'Temps de démontage',
'${event.disassemblyTime} heures',
),
Builder(
builder: (context) {
final departure = event.endDateTime.add(
Duration(hours: event.disassemblyTime),
);
return Padding(
padding: const EdgeInsets.only(left: 36.0, bottom: 4.0),
child: Text(
'Horaire de départ prévu : ${dateFormat.format(departure)}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey[700],
),
),
);
},
),
],
);
}
Widget _buildInfoRow(
BuildContext context,
IconData icon,
String label,
String value,
) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
Icon(icon, color: AppColors.rouge),
const SizedBox(width: 8),
Text(
'$label : ',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: AppColors.noir,
fontWeight: FontWeight.bold,
),
),
Text(
value,
style: Theme.of(context).textTheme.titleMedium,
),
],
),
);
}
}

View File

@@ -0,0 +1,63 @@
import 'package:flutter/material.dart';
import 'package:em2rp/models/event_model.dart';
import 'package:em2rp/utils/colors.dart';
import 'package:intl/intl.dart';
class EventDetailsNavigation extends StatelessWidget {
final List<EventModel> sortedEvents;
final int currentIndex;
final DateTime? selectedDate;
final void Function(EventModel, DateTime) onSelectEvent;
const EventDetailsNavigation({
super.key,
required this.sortedEvents,
required this.currentIndex,
required this.selectedDate,
required this.onSelectEvent,
});
@override
Widget build(BuildContext context) {
final fullDateFormat = DateFormat('EEEE d MMMM y', 'fr_FR');
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
onPressed: currentIndex > 0
? () {
final prevEvent = sortedEvents[currentIndex - 1];
onSelectEvent(prevEvent, prevEvent.startDateTime);
}
: null,
icon: const Icon(Icons.arrow_back),
color: AppColors.rouge,
),
if (selectedDate != null)
Expanded(
child: Center(
child: Text(
fullDateFormat.format(selectedDate!),
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: AppColors.rouge,
fontWeight: FontWeight.bold,
),
),
),
),
IconButton(
onPressed: currentIndex < sortedEvents.length - 1
? () {
final nextEvent = sortedEvents[currentIndex + 1];
onSelectEvent(nextEvent, nextEvent.startDateTime);
}
: null,
icon: const Icon(Icons.arrow_forward),
color: AppColors.rouge,
),
],
);
}
}

View File

@@ -0,0 +1,182 @@
import 'package:flutter/material.dart';
import 'package:em2rp/models/event_model.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:provider/provider.dart';
import 'package:em2rp/providers/event_provider.dart';
class EventStatusButton extends StatefulWidget {
final EventModel event;
final DateTime? selectedDate;
final void Function(EventModel, DateTime) onSelectEvent;
const EventStatusButton({
super.key,
required this.event,
required this.selectedDate,
required this.onSelectEvent,
});
@override
State<EventStatusButton> createState() => _EventStatusButtonState();
}
class _EventStatusButtonState extends State<EventStatusButton> {
bool _loading = false;
Future<void> _changeStatus(EventStatus newStatus) async {
if (widget.event.status == newStatus) return;
setState(() => _loading = true);
try {
await FirebaseFirestore.instance
.collection('events')
.doc(widget.event.id)
.update({'status': eventStatusToString(newStatus)});
final snap = await FirebaseFirestore.instance
.collection('events')
.doc(widget.event.id)
.get();
final updatedEvent = EventModel.fromMap(snap.data()!, widget.event.id);
widget.onSelectEvent(
updatedEvent,
widget.selectedDate ?? updatedEvent.startDateTime,
);
await Provider.of<EventProvider>(context, listen: false)
.updateEvent(updatedEvent);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erreur lors du changement de statut: $e')),
);
}
} finally {
if (mounted) {
setState(() => _loading = false);
}
}
}
@override
Widget build(BuildContext context) {
final status = widget.event.status;
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,
() => _changeStatus(EventStatus.canceled),
),
_buildLabel(texte, couleurFond),
_buildIconButton(
Icons.check,
Colors.green,
() => _changeStatus(EventStatus.confirmed),
),
];
break;
case EventStatus.confirmed:
texte = "Confirmé";
couleurFond = Colors.green;
enfants = [
_buildIconButton(
Icons.close,
Colors.red,
() => _changeStatus(EventStatus.canceled),
),
_buildIconButton(
Icons.hourglass_empty,
Colors.yellow.shade700,
() => _changeStatus(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,
() => _changeStatus(EventStatus.waitingForApproval),
),
_buildIconButton(
Icons.check,
Colors.green,
() => _changeStatus(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),
),
);
}
}

View File

@@ -1,6 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:em2rp/models/option_model.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:em2rp/utils/colors.dart'; import 'package:em2rp/utils/colors.dart';

View File

@@ -2,8 +2,6 @@ import 'package:flutter/material.dart';
import 'package:em2rp/models/user_model.dart'; import 'package:em2rp/models/user_model.dart';
import 'package:em2rp/views/widgets/user_management/user_multi_select_widget.dart'; import 'package:em2rp/views/widgets/user_management/user_multi_select_widget.dart';
import 'package:em2rp/views/widgets/inputs/dropzone_upload_widget.dart'; import 'package:em2rp/views/widgets/inputs/dropzone_upload_widget.dart';
import 'package:file_picker/file_picker.dart';
import 'package:firebase_storage/firebase_storage.dart';
class EventStaffAndDocumentsSection extends StatelessWidget { class EventStaffAndDocumentsSection extends StatelessWidget {
final List<UserModel> allUsers; final List<UserModel> allUsers;

View File

@@ -564,7 +564,7 @@ class _CreateOptionDialogState extends State<_CreateOptionDialog> {
} }
try { try {
// Debug : afficher le contenu envoyé // Debug : afficher le contenu envoyé
print('Enregistrement option avec eventTypes : ' + _selectedTypes.toString() + '\u001b[0m'); print('Enregistrement option avec eventTypes : $_selectedTypes\u001b');
await FirebaseFirestore.instance.collection('options').add({ await FirebaseFirestore.instance.collection('options').add({
'name': name, 'name': name,
'details': _detailsController.text.trim(), 'details': _detailsController.text.trim(),
@@ -574,7 +574,7 @@ class _CreateOptionDialogState extends State<_CreateOptionDialog> {
}); });
Navigator.pop(context, true); Navigator.pop(context, true);
} catch (e) { } catch (e) {
setState(() => _error = 'Erreur lors de la création : ' + e.toString() + '\nEventTypes=' + _selectedTypes.toString()); setState(() => _error = 'Erreur lors de la création : $e\nEventTypes=$_selectedTypes');
} }
}, },
child: _checkingName child: _checkingName