V1 calendrier

This commit is contained in:
ElPoyo
2025-10-10 14:58:05 +02:00
parent 080fb7d077
commit aae68f8ab7
26 changed files with 1328 additions and 847 deletions

View File

@@ -34,5 +34,13 @@
"*.local" "*.local"
] ]
} }
] ],
"hosting": {
"public": "build/web",
"ignore": [
"firebase.json",
"**/.*",
"**/node_modules/**"
]
}
} }

View File

@@ -9,6 +9,11 @@
const {onRequest} = require("firebase-functions/v2/https"); const {onRequest} = require("firebase-functions/v2/https");
const logger = require("firebase-functions/logger"); const logger = require("firebase-functions/logger");
const admin = require('firebase-admin');
const { Storage } = require('@google-cloud/storage');
admin.initializeApp();
const storage = new Storage();
// Create and deploy your first functions // Create and deploy your first functions
// https://firebase.google.com/docs/functions/get-started // https://firebase.google.com/docs/functions/get-started
@@ -18,22 +23,12 @@ const logger = require("firebase-functions/logger");
// response.send("Hello from Firebase!"); // response.send("Hello from Firebase!");
// }); // });
const functions = require('firebase-functions');
const admin = require('firebase-admin');
const { Storage } = require('@google-cloud/storage');
admin.initializeApp();
const storage = new Storage();
// Nouvelle version HTTP sécurisée // Nouvelle version HTTP sécurisée
exports.moveEventFileV2 = functions.https.onRequest(async (req, res) => { exports.moveEventFileV2 = onRequest({cors: true}, async (req, res) => {
// Ajout des headers CORS // La gestion CORS est maintenant gérée par l'option {cors: true}
res.set('Access-Control-Allow-Origin', '*'); // La vérification pour les requêtes OPTIONS n'est plus nécessaire
res.set('Access-Control-Allow-Headers', 'Content-Type, Authorization');
if (req.method === 'OPTIONS') {
res.set('Access-Control-Allow-Methods', 'POST, OPTIONS');
res.status(204).send('');
return;
}
// Vérification du token Firebase dans l'en-tête Authorization // Vérification du token Firebase dans l'en-tête Authorization
let uid = null; let uid = null;
if (req.headers.authorization && req.headers.authorization.startsWith('Bearer ')) { if (req.headers.authorization && req.headers.authorization.startsWith('Bearer ')) {
@@ -42,11 +37,13 @@ exports.moveEventFileV2 = functions.https.onRequest(async (req, res) => {
const decodedToken = await admin.auth().verifyIdToken(idToken); const decodedToken = await admin.auth().verifyIdToken(idToken);
uid = decodedToken.uid; uid = decodedToken.uid;
} catch (e) { } catch (e) {
res.status(401).json({ error: 'Invalid token' }); logger.error("Error while verifying Firebase ID token:", e);
res.status(401).json({ error: 'Unauthorized: Invalid token' });
return; return;
} }
} else { } else {
res.status(401).json({ error: 'No token provided' }); logger.warn("No Firebase ID token was passed as a Bearer token in the Authorization header.");
res.status(401).json({ error: 'Unauthorized: No token provided' });
return; return;
} }
@@ -68,6 +65,7 @@ exports.moveEventFileV2 = functions.https.onRequest(async (req, res) => {
}); });
res.status(200).json({ url }); res.status(200).json({ url });
} catch (error) { } catch (error) {
logger.error("Error moving file:", error);
res.status(500).json({ error: error.message }); res.status(500).json({ error: error.message });
} }
}); });

View File

@@ -1,5 +1,5 @@
class Env { class Env {
static const bool isDevelopment = true; static const bool isDevelopment = false;
// Configuration de l'auto-login en développement // Configuration de l'auto-login en développement
static const String devAdminEmail = 'paul.fournel@em2events.fr'; static const String devAdminEmail = 'paul.fournel@em2events.fr';

View File

@@ -0,0 +1,386 @@
import 'package:flutter/material.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:file_picker/file_picker.dart';
import 'package:em2rp/models/event_model.dart';
import 'package:em2rp/models/event_type_model.dart';
import 'package:em2rp/models/user_model.dart';
import 'package:em2rp/services/event_form_service.dart';
import 'package:provider/provider.dart';
import 'package:em2rp/providers/event_provider.dart';
import 'package:em2rp/providers/local_user_provider.dart';
class EventFormController extends ChangeNotifier {
// Controllers
final TextEditingController nameController = TextEditingController();
final TextEditingController descriptionController = TextEditingController();
final TextEditingController basePriceController = TextEditingController();
final TextEditingController installationController = TextEditingController();
final TextEditingController disassemblyController = TextEditingController();
final TextEditingController addressController = TextEditingController();
// State variables
DateTime? _startDateTime;
DateTime? _endDateTime;
bool _isLoading = false;
String? _error;
String? _success;
String? _selectedEventTypeId;
List<EventType> _eventTypes = [];
bool _isLoadingEventTypes = true;
List<String> _selectedUserIds = [];
List<UserModel> _allUsers = [];
bool _isLoadingUsers = true;
List<Map<String, String>> _uploadedFiles = [];
List<Map<String, dynamic>> _selectedOptions = [];
bool _formChanged = false;
EventStatus _selectedStatus = EventStatus.waitingForApproval;
// Getters
DateTime? get startDateTime => _startDateTime;
DateTime? get endDateTime => _endDateTime;
bool get isLoading => _isLoading;
String? get error => _error;
String? get success => _success;
String? get selectedEventTypeId => _selectedEventTypeId;
List<EventType> get eventTypes => _eventTypes;
bool get isLoadingEventTypes => _isLoadingEventTypes;
List<String> get selectedUserIds => _selectedUserIds;
List<UserModel> get allUsers => _allUsers;
bool get isLoadingUsers => _isLoadingUsers;
List<Map<String, String>> get uploadedFiles => _uploadedFiles;
List<Map<String, dynamic>> get selectedOptions => _selectedOptions;
bool get formChanged => _formChanged;
EventStatus get selectedStatus => _selectedStatus;
EventFormController() {
_setupListeners();
}
void _setupListeners() {
nameController.addListener(_onAnyFieldChanged);
basePriceController.addListener(_onAnyFieldChanged);
installationController.addListener(_onAnyFieldChanged);
disassemblyController.addListener(_onAnyFieldChanged);
addressController.addListener(_onAnyFieldChanged);
descriptionController.addListener(_onAnyFieldChanged);
}
void _onAnyFieldChanged() {
if (!_formChanged) {
_formChanged = true;
notifyListeners();
}
}
Future<void> initialize([EventModel? existingEvent]) async {
await Future.wait([
_fetchUsers(),
_fetchEventTypes(),
]);
if (existingEvent != null) {
_populateFromEvent(existingEvent);
} else {
_selectedStatus = EventStatus.waitingForApproval;
}
notifyListeners();
}
void _populateFromEvent(EventModel event) {
nameController.text = event.name;
descriptionController.text = event.description;
basePriceController.text = event.basePrice.toStringAsFixed(2);
installationController.text = event.installationTime.toString();
disassemblyController.text = event.disassemblyTime.toString();
addressController.text = event.address;
_startDateTime = event.startDateTime;
_endDateTime = event.endDateTime;
_selectedEventTypeId = event.eventTypeId.isNotEmpty ? event.eventTypeId : null;
_selectedUserIds = event.workforce.map((ref) => ref.id).toList();
_uploadedFiles = List<Map<String, String>>.from(event.documents);
_selectedOptions = List<Map<String, dynamic>>.from(event.options);
_selectedStatus = event.status;
}
Future<void> _fetchUsers() async {
try {
_allUsers = await EventFormService.fetchUsers();
_isLoadingUsers = false;
} catch (e) {
_error = e.toString();
_isLoadingUsers = false;
}
notifyListeners();
}
Future<void> _fetchEventTypes() async {
try {
_eventTypes = await EventFormService.fetchEventTypes();
_isLoadingEventTypes = false;
} catch (e) {
_error = e.toString();
_isLoadingEventTypes = false;
}
notifyListeners();
}
void setStartDateTime(DateTime? dateTime) {
_startDateTime = dateTime;
if (_endDateTime != null &&
dateTime != null &&
(_endDateTime!.isBefore(dateTime) || _endDateTime!.isAtSameMomentAs(dateTime))) {
_endDateTime = null;
}
_onAnyFieldChanged();
notifyListeners();
}
void setEndDateTime(DateTime? dateTime) {
_endDateTime = dateTime;
_onAnyFieldChanged();
notifyListeners();
}
void onEventTypeChanged(String? newTypeId, BuildContext context) {
if (newTypeId == _selectedEventTypeId) return;
final oldEventTypeIndex = _selectedEventTypeId != null
? _eventTypes.indexWhere((et) => et.id == _selectedEventTypeId)
: -1;
final EventType? oldEventType = oldEventTypeIndex != -1 ? _eventTypes[oldEventTypeIndex] : null;
_selectedEventTypeId = newTypeId;
if (newTypeId != null) {
final selectedType = _eventTypes.firstWhere((et) => et.id == newTypeId);
final defaultPrice = selectedType.defaultPrice;
final currentPrice = double.tryParse(basePriceController.text.replaceAll(',', '.'));
final oldDefaultPrice = oldEventType?.defaultPrice;
if (basePriceController.text.isEmpty ||
(currentPrice != null && oldDefaultPrice != null && currentPrice == oldDefaultPrice)) {
basePriceController.text = defaultPrice.toStringAsFixed(2);
}
final before = _selectedOptions.length;
_selectedOptions.removeWhere((opt) {
final types = opt['compatibleTypes'] as List<dynamic>?;
if (types == null) return true;
return !types.contains(selectedType.name);
});
if (_selectedOptions.length < before) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Certaines options ont été retirées car non compatibles avec "${selectedType.name}".')),
);
}
} else {
_selectedOptions.clear();
}
_onAnyFieldChanged();
notifyListeners();
}
void setSelectedUserIds(List<String> userIds) {
_selectedUserIds = userIds;
_onAnyFieldChanged();
notifyListeners();
}
void setUploadedFiles(List<Map<String, String>> files) {
_uploadedFiles = files;
_onAnyFieldChanged();
notifyListeners();
}
void setSelectedOptions(List<Map<String, dynamic>> options) {
_selectedOptions = options;
_onAnyFieldChanged();
notifyListeners();
}
Future<void> pickAndUploadFiles() async {
final result = await FilePicker.platform.pickFiles(allowMultiple: true, withData: true);
if (result != null && result.files.isNotEmpty) {
_isLoading = true;
_error = null;
notifyListeners();
try {
final files = await EventFormService.uploadFiles(result.files);
_uploadedFiles.addAll(files);
_onAnyFieldChanged();
} catch (e) {
_error = 'Erreur lors de l\'upload : $e';
} finally {
_isLoading = false;
notifyListeners();
}
}
}
bool validateForm() {
return nameController.text.isNotEmpty &&
_startDateTime != null &&
_endDateTime != null &&
_selectedEventTypeId != null &&
addressController.text.isNotEmpty &&
(_endDateTime!.isAfter(_startDateTime!));
}
Future<bool> submitForm(BuildContext context, {EventModel? existingEvent}) async {
if (!validateForm()) {
_error = "Veuillez remplir tous les champs obligatoires.";
notifyListeners();
return false;
}
_isLoading = true;
_error = null;
_success = null;
notifyListeners();
try {
final eventTypeRef = _selectedEventTypeId != null
? FirebaseFirestore.instance.collection('eventTypes').doc(_selectedEventTypeId)
: null;
if (existingEvent != null) {
// Mode édition
// Gérer les nouveaux fichiers uploadés s'il y en a
List<Map<String, String>> finalDocuments = List<Map<String, String>>.from(_uploadedFiles);
// Identifier les nouveaux fichiers (ceux qui ont une URL temp)
final newFiles = _uploadedFiles.where((file) =>
file['url']?.contains('events/temp/') ?? false).toList();
if (newFiles.isNotEmpty) {
// Déplacer les nouveaux fichiers vers le dossier de l'événement
final movedFiles = await EventFormService.moveFilesToEvent(newFiles, existingEvent.id);
// Remplacer les URLs temporaires par les nouvelles URLs
for (int i = 0; i < finalDocuments.length; i++) {
final tempFile = finalDocuments[i];
final movedFile = movedFiles.firstWhere(
(moved) => moved['name'] == tempFile['name'],
orElse: () => tempFile,
);
finalDocuments[i] = movedFile;
}
}
final updatedEvent = EventModel(
id: existingEvent.id,
name: nameController.text.trim(),
description: descriptionController.text.trim(),
startDateTime: _startDateTime!,
endDateTime: _endDateTime!,
basePrice: double.tryParse(basePriceController.text.replaceAll(',', '.')) ?? 0.0,
installationTime: int.tryParse(installationController.text) ?? 0,
disassemblyTime: int.tryParse(disassemblyController.text) ?? 0,
eventTypeId: _selectedEventTypeId!,
eventTypeRef: eventTypeRef,
customerId: existingEvent.customerId,
address: addressController.text.trim(),
workforce: _selectedUserIds
.map((id) => FirebaseFirestore.instance.collection('users').doc(id))
.toList(),
latitude: existingEvent.latitude,
longitude: existingEvent.longitude,
documents: finalDocuments,
options: _selectedOptions,
status: _selectedStatus,
);
await EventFormService.updateEvent(updatedEvent);
// Recharger les événements après modification
final localUserProvider = Provider.of<LocalUserProvider>(context, listen: false);
final eventProvider = Provider.of<EventProvider>(context, listen: false);
final userId = localUserProvider.uid;
final canViewAllEvents = localUserProvider.hasPermission('view_all_events');
if (userId != null) {
await eventProvider.loadUserEvents(userId, canViewAllEvents: canViewAllEvents);
}
_success = "Événement modifié avec succès !";
} else {
// Mode création
final newEvent = EventModel(
id: '',
name: nameController.text.trim(),
description: descriptionController.text.trim(),
startDateTime: _startDateTime!,
endDateTime: _endDateTime!,
basePrice: double.tryParse(basePriceController.text.replaceAll(',', '.')) ?? 0.0,
installationTime: int.tryParse(installationController.text) ?? 0,
disassemblyTime: int.tryParse(disassemblyController.text) ?? 0,
eventTypeId: _selectedEventTypeId!,
eventTypeRef: eventTypeRef,
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,
status: _selectedStatus,
);
final eventId = await EventFormService.createEvent(newEvent);
final newFiles = await EventFormService.moveFilesToEvent(_uploadedFiles, eventId);
await EventFormService.updateEventDocuments(eventId, newFiles);
// Reload events
final localUserProvider = Provider.of<LocalUserProvider>(context, listen: false);
final eventProvider = Provider.of<EventProvider>(context, listen: false);
final userId = localUserProvider.uid;
final canViewAllEvents = localUserProvider.hasPermission('view_all_events');
if (userId != null) {
await eventProvider.loadUserEvents(userId, canViewAllEvents: canViewAllEvents);
}
_success = "Événement créé avec succès !";
}
_formChanged = false;
notifyListeners();
return true;
} catch (e) {
_error = "Erreur lors de la sauvegarde : $e";
notifyListeners();
return false;
} finally {
_isLoading = false;
notifyListeners();
}
}
void clearError() {
_error = null;
notifyListeners();
}
void clearSuccess() {
_success = null;
notifyListeners();
}
@override
void dispose() {
nameController.dispose();
descriptionController.dispose();
basePriceController.dispose();
installationController.dispose();
disassemblyController.dispose();
addressController.dispose();
super.dispose();
}
}

View File

@@ -1,5 +1,4 @@
import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:latlong2/latlong.dart';
enum EventStatus { enum EventStatus {
confirmed, confirmed,
@@ -41,6 +40,7 @@ class EventModel {
final int installationTime; final int installationTime;
final int disassemblyTime; final int disassemblyTime;
final String eventTypeId; final String eventTypeId;
final DocumentReference? eventTypeRef;
final String customerId; final String customerId;
final String address; final String address;
final double latitude; final double latitude;
@@ -60,6 +60,7 @@ class EventModel {
required this.installationTime, required this.installationTime,
required this.disassemblyTime, required this.disassemblyTime,
required this.eventTypeId, required this.eventTypeId,
this.eventTypeRef,
required this.customerId, required this.customerId,
required this.address, required this.address,
required this.latitude, required this.latitude,
@@ -79,7 +80,7 @@ class EventModel {
final docs = docsRaw is List final docs = docsRaw is List
? docsRaw.map<Map<String, String>>((e) { ? docsRaw.map<Map<String, String>>((e) {
if (e is Map) { if (e is Map) {
return Map<String, String>.from(e as Map); return Map<String, String>.from(e);
} else if (e is String) { } else if (e is String) {
final fileName = Uri.decodeComponent( final fileName = Uri.decodeComponent(
e.split('/').last.split('?').first, e.split('/').last.split('?').first,
@@ -94,7 +95,7 @@ class EventModel {
final options = optionsRaw is List final options = optionsRaw is List
? optionsRaw.map<Map<String, dynamic>>((e) { ? optionsRaw.map<Map<String, dynamic>>((e) {
if (e is Map) { if (e is Map) {
return Map<String, dynamic>.from(e as Map); return Map<String, dynamic>.from(e);
} else { } else {
return {}; return {};
} }
@@ -112,7 +113,10 @@ class EventModel {
disassemblyTime: map['DisassemblyTime'] ?? 0, disassemblyTime: map['DisassemblyTime'] ?? 0,
eventTypeId: map['EventType'] is DocumentReference eventTypeId: map['EventType'] is DocumentReference
? (map['EventType'] as DocumentReference).id ? (map['EventType'] as DocumentReference).id
: '', : map['EventType'] ?? '',
eventTypeRef: map['EventType'] is DocumentReference
? map['EventType'] as DocumentReference
: null,
customerId: map['customer'] is DocumentReference customerId: map['customer'] is DocumentReference
? (map['customer'] as DocumentReference).id ? (map['customer'] as DocumentReference).id
: '', : '',
@@ -135,8 +139,8 @@ class EventModel {
'BasePrice': basePrice, 'BasePrice': basePrice,
'InstallationTime': installationTime, 'InstallationTime': installationTime,
'DisassemblyTime': disassemblyTime, 'DisassemblyTime': disassemblyTime,
'EventType': eventTypeId, 'EventType': eventTypeId.isNotEmpty ? FirebaseFirestore.instance.collection('eventTypes').doc(eventTypeId) : null,
'customer': customerId, 'customer': customerId.isNotEmpty ? FirebaseFirestore.instance.collection('customers').doc(customerId) : null,
'Address': address, 'Address': address,
'Position': GeoPoint(latitude, longitude), 'Position': GeoPoint(latitude, longitude),
'Latitude': latitude, 'Latitude': latitude,

View File

@@ -0,0 +1,32 @@
import 'package:cloud_firestore/cloud_firestore.dart';
class EventType {
final String id;
final String name;
final double defaultPrice;
EventType({
required this.id,
required this.name,
required this.defaultPrice,
});
factory EventType.fromFirestore(DocumentSnapshot doc) {
Map<String, dynamic> data = doc.data() as Map<String, dynamic>;
double price = 0.0;
final priceData = data['defaultPrice'];
if (priceData is num) {
price = priceData.toDouble();
} else if (priceData is String) {
price = double.tryParse(priceData.replaceAll(',', '.')) ?? 0.0;
}
return EventType(
id: doc.id,
name: data['name'] ?? '',
defaultPrice: price,
);
}
}

View File

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

View File

@@ -3,7 +3,6 @@ import '../models/user_model.dart';
import '../services/user_service.dart'; import '../services/user_service.dart';
import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart'; import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/foundation.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';
@@ -130,7 +129,7 @@ class UsersProvider with ChangeNotifier {
await _auth.sendPasswordResetEmail( await _auth.sendPasswordResetEmail(
email: user.email, email: user.email,
actionCodeSettings: ActionCodeSettings( actionCodeSettings: ActionCodeSettings(
url: 'http://localhost:63337/finishSignUp?email=${user.email}', url: 'http://app.em2events.fr/finishSignUp?email=${user.email}',
handleCodeInApp: true, handleCodeInApp: true,
androidPackageName: 'com.em2rp.app', androidPackageName: 'com.em2rp.app',
androidInstallApp: true, androidInstallApp: true,

View File

@@ -0,0 +1,143 @@
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:firebase_storage/firebase_storage.dart';
import 'package:file_picker/file_picker.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';
import 'package:em2rp/models/event_model.dart';
import 'package:em2rp/models/event_type_model.dart';
import 'package:em2rp/models/user_model.dart';
import 'dart:developer' as developer;
class EventFormService {
static Future<List<EventType>> fetchEventTypes() async {
developer.log('Fetching event types from Firestore...', name: 'EventFormService');
try {
final snapshot = await FirebaseFirestore.instance.collection('eventTypes').get();
final eventTypes = snapshot.docs.map((doc) => EventType.fromFirestore(doc)).toList();
developer.log('${eventTypes.length} event types loaded.', name: 'EventFormService');
return eventTypes;
} catch (e, s) {
developer.log('Error fetching event types', name: 'EventFormService', error: e, stackTrace: s);
throw Exception("Could not load event types. Please check Firestore permissions.");
}
}
static Future<List<UserModel>> fetchUsers() async {
try {
final snapshot = await FirebaseFirestore.instance.collection('users').get();
return snapshot.docs.map((doc) => UserModel.fromMap(doc.data(), doc.id)).toList();
} catch (e) {
developer.log('Error fetching users', name: 'EventFormService', error: e);
throw Exception("Could not load users.");
}
}
static Future<List<Map<String, String>>> uploadFiles(List<PlatformFile> files) async {
List<Map<String, String>> uploadedFiles = [];
for (final file in 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();
uploadedFiles.add({'name': fileName, 'url': url});
} else {
throw Exception("Impossible de lire le fichier $fileName");
}
}
return uploadedFiles;
}
static 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;
}
}
static Future<String> createEvent(EventModel event) async {
final docRef = await FirebaseFirestore.instance.collection('events').add(event.toMap());
return docRef.id;
}
static Future<void> updateEvent(EventModel event) async {
final docRef = FirebaseFirestore.instance.collection('events').doc(event.id);
await docRef.update(event.toMap());
}
static Future<List<Map<String, String>>> moveFilesToEvent(
List<Map<String, String>> tempFiles, String eventId) async {
List<Map<String, String>> newFiles = [];
for (final file in tempFiles) {
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});
}
}
return newFiles;
}
static Future<void> updateEventDocuments(String eventId, List<Map<String, String>> documents) async {
await FirebaseFirestore.instance
.collection('events')
.doc(eventId)
.update({'documents': documents});
}
}

View File

@@ -1,6 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:em2rp/models/role_model.dart';
import 'package:em2rp/providers/local_user_provider.dart'; import 'package:em2rp/providers/local_user_provider.dart';
class PermissionGate extends StatelessWidget { class PermissionGate extends StatelessWidget {

View File

@@ -120,22 +120,7 @@ class _CalendarPageState extends State<CalendarPage> {
return Scaffold( return Scaffold(
appBar: CustomAppBar( appBar: CustomAppBar(
title: _getMonthName(_focusedDay.month), title: "Calendrier",
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(),
@@ -149,8 +134,8 @@ class _CalendarPageState extends State<CalendarPage> {
), ),
); );
}, },
child: const Icon(Icons.add, color: Colors.red),
tooltip: 'Ajouter un événement', tooltip: 'Ajouter un événement',
child: const Icon(Icons.add, color: Colors.red),
) )
: null, : null,
); );
@@ -252,7 +237,7 @@ class _CalendarPageState extends State<CalendarPage> {
left: 0, left: 0,
right: 0, right: 0,
height: _calendarCollapsed ? 0 : null, height: _calendarCollapsed ? 0 : null,
child: Container( child: SizedBox(
height: MediaQuery.of(context).size.height, height: MediaQuery.of(context).size.height,
child: Column( child: Column(
children: [ children: [
@@ -360,7 +345,7 @@ class _CalendarPageState extends State<CalendarPage> {
left: 0, left: 0,
right: 0, right: 0,
bottom: 0, bottom: 0,
child: Container( child: SizedBox(
height: MediaQuery.of(context).size.height, height: MediaQuery.of(context).size.height,
child: Column( child: Column(
children: [ children: [

View File

@@ -1,27 +1,13 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:em2rp/providers/event_provider.dart';
import 'package:em2rp/models/event_model.dart'; import 'package:em2rp/models/event_model.dart';
import 'package:intl/intl.dart'; import 'package:em2rp/controllers/event_form_controller.dart';
import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:em2rp/views/widgets/event_form/event_basic_info_section.dart';
import 'package:em2rp/views/widgets/inputs/int_stepper_field.dart'; import 'package:em2rp/views/widgets/event_form/event_details_section.dart';
import 'package:em2rp/models/user_model.dart'; import 'package:em2rp/views/widgets/event_form/event_staff_and_documents_section.dart';
import 'package:em2rp/views/widgets/image/profile_picture.dart'; import 'package:em2rp/views/widgets/event_form/event_form_actions.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'; import 'package:em2rp/views/widgets/inputs/option_selector_widget.dart';
// ignore: avoid_web_libraries_in_flutter import 'package:flutter/foundation.dart';
import 'dart:html' as html;
class EventAddEditPage extends StatefulWidget { class EventAddEditPage extends StatefulWidget {
final EventModel? event; final EventModel? event;
@@ -33,166 +19,27 @@ class EventAddEditPage extends StatefulWidget {
class _EventAddEditPageState extends State<EventAddEditPage> { class _EventAddEditPageState extends State<EventAddEditPage> {
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
final TextEditingController _nameController = TextEditingController(); late EventFormController _controller;
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; bool get isEditMode => widget.event != null;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_descriptionController.addListener(_handleDescriptionChange); _controller = EventFormController();
_fetchUsers(); _controller.initialize(widget.event);
_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 @override
void dispose() { void dispose() {
_nameController.dispose(); _controller.dispose();
_descriptionController.dispose();
_basePriceController.dispose();
_installationController.dispose();
_disassemblyController.dispose();
_addressController.dispose();
_removeBeforeUnloadListener();
super.dispose(); 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 { Future<bool> _onWillPop() async {
if (!_formChanged) return true; if (!_controller.formChanged) return true;
if (!mounted) return true;
final shouldLeave = await showDialog<bool>( final shouldLeave = await showDialog<bool>(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
@@ -214,262 +61,59 @@ class _EventAddEditPageState extends State<EventAddEditPage> {
return shouldLeave ?? false; 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 { Future<void> _submit() async {
if (!_formKey.currentState!.validate() || if (!_formKey.currentState!.validate()) {
_startDateTime == null || ScaffoldMessenger.of(context).showSnackBar(
_endDateTime == null || const SnackBar(
_selectedEventType == null || content: Text('Veuillez remplir tous les champs obligatoires.'),
_addressController.text.isEmpty) return; backgroundColor: Colors.red,
if (_endDateTime!.isBefore(_startDateTime!) || ),
_endDateTime!.isAtSameMomentAs(_startDateTime!)) { );
setState(() {
_error = "La date de fin doit être postérieure à la date de début.";
});
return; 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) { final success = await _controller.submitForm(context, existingEvent: widget.event);
return Padding( if (success && mounted) {
padding: const EdgeInsets.only(top: 16.0, bottom: 8.0), Navigator.of(context).pop();
child: Align( }
alignment: Alignment.centerLeft,
child: Text(
title,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
),
);
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context);
final isMobile = MediaQuery.of(context).size.width < 600; final isMobile = MediaQuery.of(context).size.width < 600;
return WillPopScope(
onWillPop: _onWillPop, return ChangeNotifierProvider.value(
child: Scaffold( value: _controller,
appBar: AppBar( child: PopScope(
title: canPop: false,
Text(isEditMode ? 'Modifier un événement' : 'Créer un événement'), onPopInvokedWithResult: (didPop, result) async {
), if (didPop) return;
body: Center( final shouldPop = await _onWillPop();
child: SingleChildScrollView( if (shouldPop && mounted) {
child: (isMobile Navigator.of(context).pop();
? Padding( }
padding: const EdgeInsets.symmetric( },
horizontal: 16, vertical: 12), child: Scaffold(
child: _buildFormContent(isMobile), appBar: AppBar(
) title: Text(isEditMode ? 'Modifier un événement' : 'Créer un événement'),
: Card( ),
elevation: 6, body: Center(
margin: const EdgeInsets.all(24), child: SingleChildScrollView(
shape: RoundedRectangleBorder( child: (isMobile
borderRadius: BorderRadius.circular(18)), ? Padding(
child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
padding: const EdgeInsets.symmetric(
horizontal: 32, vertical: 32),
child: _buildFormContent(isMobile), 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),
),
)),
),
), ),
), ),
), ),
@@ -477,373 +121,105 @@ class _EventAddEditPageState extends State<EventAddEditPage> {
} }
Widget _buildFormContent(bool isMobile) { Widget _buildFormContent(bool isMobile) {
return Form( return Consumer<EventFormController>(
key: _formKey, builder: (context, controller, child) {
child: Column( // Trouver le nom du type d'événement pour le passer au sélecteur d'options
mainAxisSize: MainAxisSize.min, final selectedEventTypeIndex = controller.selectedEventTypeId != null
crossAxisAlignment: CrossAxisAlignment.stretch, ? controller.eventTypes.indexWhere((et) => et.id == controller.selectedEventTypeId)
children: [ : -1;
Padding( final selectedEventType = selectedEventTypeIndex != -1
padding: const EdgeInsets.only(top: 0.0, bottom: 4.0), ? controller.eventTypes[selectedEventTypeIndex]
child: Align( : null;
alignment: Alignment.centerLeft, final selectedEventTypeName = selectedEventType?.name;
child: Text(
'Informations principales', return Form(
style: key: _formKey,
const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), child: Column(
), mainAxisSize: MainAxisSize.min,
), crossAxisAlignment: CrossAxisAlignment.stretch,
),
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: [ children: [
Expanded( EventBasicInfoSection(
child: GestureDetector( nameController: controller.nameController,
onTap: () async { basePriceController: controller.basePriceController,
final picked = await showDatePicker( eventTypes: controller.eventTypes,
context: context, isLoadingEventTypes: controller.isLoadingEventTypes,
initialDate: DateTime.now(), selectedEventTypeId: controller.selectedEventTypeId,
firstDate: DateTime(2020), startDateTime: controller.startDateTime,
lastDate: DateTime(2099), endDateTime: controller.endDateTime,
); onEventTypeChanged: (typeId) => controller.onEventTypeChanged(typeId, context),
if (picked != null) { onStartDateTimeChanged: controller.setStartDateTime,
final time = await showTimePicker( onEndDateTimeChanged: controller.setEndDateTime,
context: context, onAnyFieldChanged: () {}, // Géré automatiquement par le contrôleur
initialTime: TimeOfDay.now(), ),
); const SizedBox(height: 16),
if (time != null) { OptionSelectorWidget(
setState(() { eventType: selectedEventTypeName,
_startDateTime = DateTime( selectedOptions: controller.selectedOptions,
picked.year, onChanged: controller.setSelectedOptions,
picked.month, onRemove: (name) {
picked.day, final newOptions = List<Map<String, dynamic>>.from(controller.selectedOptions);
time.hour, newOptions.removeWhere((o) => o['name'] == name);
time.minute, controller.setSelectedOptions(newOptions);
); },
if (_endDateTime != null && eventTypeRequired: controller.selectedEventTypeId == null,
(_endDateTime!.isBefore(_startDateTime!) || isMobile: isMobile,
_endDateTime! ),
.isAtSameMomentAs(_startDateTime!))) { EventDetailsSection(
_endDateTime = null; descriptionController: controller.descriptionController,
} installationController: controller.installationController,
}); disassemblyController: controller.disassemblyController,
} addressController: controller.addressController,
} isMobile: isMobile,
}, onAnyFieldChanged: () {}, // Géré automatiquement par le contrôleur
child: AbsorbPointer( ),
child: TextFormField( EventStaffAndDocumentsSection(
readOnly: true, allUsers: controller.allUsers,
decoration: InputDecoration( selectedUserIds: controller.selectedUserIds,
labelText: 'Début', onUserSelectionChanged: controller.setSelectedUserIds,
border: const OutlineInputBorder(), isLoadingUsers: controller.isLoadingUsers,
prefixIcon: const Icon(Icons.calendar_today), uploadedFiles: controller.uploadedFiles,
suffixIcon: const Icon(Icons.edit_calendar), onFilesChanged: controller.setUploadedFiles,
), isLoading: controller.isLoading,
controller: TextEditingController( error: controller.error,
text: _startDateTime == null success: controller.success,
? '' isMobile: isMobile,
: DateFormat('dd/MM/yyyy HH:mm') onPickAndUploadFiles: controller.pickAndUploadFiles,
.format(_startDateTime!), ),
), if (controller.error != null)
validator: (v) => Padding(
_startDateTime == null ? 'Champ requis' : null, padding: const EdgeInsets.only(top: 16.0),
), child: Text(
controller.error!,
style: const TextStyle(color: Colors.red),
textAlign: TextAlign.center,
), ),
), ),
), if (controller.success != null)
const SizedBox(width: 16), Padding(
Expanded( padding: const EdgeInsets.only(top: 16.0),
child: GestureDetector( child: Text(
onTap: _startDateTime == null controller.success!,
? null style: const TextStyle(color: Colors.green),
: () async { textAlign: TextAlign.center,
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,
),
), ),
), ),
EventFormActions(
isLoading: controller.isLoading,
isEditMode: isEditMode,
onCancel: () async {
final shouldLeave = await _onWillPop();
if (shouldLeave && mounted) {
Navigator.of(context).pop();
}
},
onSubmit: _submit,
onSetConfirmed: !isEditMode ? () {
} : 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,
),
),
],
),
); );
} }
} }

View File

@@ -115,7 +115,7 @@ class _UserManagementPageState extends State<UserManagementPage> {
List<RoleModel> availableRoles = []; List<RoleModel> availableRoles = [];
bool isLoadingRoles = true; bool isLoadingRoles = true;
Future<void> _loadRoles() async { Future<void> loadRoles() async {
final snapshot = final snapshot =
await FirebaseFirestore.instance.collection('roles').get(); await FirebaseFirestore.instance.collection('roles').get();
availableRoles = snapshot.docs availableRoles = snapshot.docs
@@ -145,7 +145,7 @@ class _UserManagementPageState extends State<UserManagementPage> {
showDialog( showDialog(
context: context, context: context,
builder: (context) => FutureBuilder( builder: (context) => FutureBuilder(
future: _loadRoles(), future: loadRoles(),
builder: (context, snapshot) { builder: (context, snapshot) {
return Dialog( return Dialog(
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
@@ -204,7 +204,7 @@ class _UserManagementPageState extends State<UserManagementPage> {
isLoadingRoles isLoadingRoles
? const CircularProgressIndicator() ? const CircularProgressIndicator()
: DropdownButtonFormField<String>( : DropdownButtonFormField<String>(
value: selectedRoleId, initialValue: selectedRoleId,
decoration: buildInputDecoration('Rôle', decoration: buildInputDecoration('Rôle',
Icons.admin_panel_settings_outlined), Icons.admin_panel_settings_outlined),
items: availableRoles.map((role) { items: availableRoles.map((role) {

View File

@@ -81,7 +81,7 @@ class _ForgotPasswordDialogState extends State<ForgotPasswordDialogWidget> {
), ),
actions: <Widget>[ actions: <Widget>[
TextButton( TextButton(
child: const Text('Terminer'), child: const Text('Annuler'),
onPressed: () { onPressed: () {
Navigator.of(context).pop(); Navigator.of(context).pop();
}, },

View File

@@ -87,13 +87,31 @@ class EventDetails extends StatelessWidget {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Row( Row(
crossAxisAlignment: CrossAxisAlignment.start, // Optionnel mais recommandé pour bien aligner
children: [ children: [
SelectableText( // On remplace le SelectableText par une Column
event.name, Expanded( // Utiliser Expanded pour que le texte ne déborde pas
style: Theme.of(context).textTheme.headlineMedium?.copyWith( child: Column(
color: AppColors.noir, crossAxisAlignment: CrossAxisAlignment.start, // Aligne les textes à gauche
fontWeight: FontWeight.bold, children: [
// 1. Votre titre original
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), const SizedBox(width: 12),
_buildStatusIcon(event.status), _buildStatusIcon(event.status),
@@ -119,7 +137,7 @@ class EventDetails extends StatelessWidget {
Padding( Padding(
padding: const EdgeInsets.symmetric(vertical: 12.0), padding: const EdgeInsets.symmetric(vertical: 12.0),
child: _FirestoreStatusButton( child: _FirestoreStatusButton(
eventId: event.id, eventId: event.id,
currentStatus: event.status, currentStatus: event.status,
onStatusChanged: (newStatus) async { onStatusChanged: (newStatus) async {
await FirebaseFirestore.instance await FirebaseFirestore.instance

View File

@@ -0,0 +1,221 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:intl/intl.dart';
import 'package:em2rp/models/event_type_model.dart';
class EventBasicInfoSection extends StatelessWidget {
final TextEditingController nameController;
final TextEditingController basePriceController;
final List<EventType> eventTypes;
final bool isLoadingEventTypes;
final String? selectedEventTypeId;
final DateTime? startDateTime;
final DateTime? endDateTime;
final Function(String?) onEventTypeChanged;
final Function(DateTime?) onStartDateTimeChanged;
final Function(DateTime?) onEndDateTimeChanged;
final VoidCallback onAnyFieldChanged;
const EventBasicInfoSection({
super.key,
required this.nameController,
required this.basePriceController,
required this.eventTypes,
required this.isLoadingEventTypes,
required this.selectedEventTypeId,
required this.startDateTime,
required this.endDateTime,
required this.onEventTypeChanged,
required this.onStartDateTimeChanged,
required this.onEndDateTimeChanged,
required this.onAnyFieldChanged,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildSectionTitle('Informations principales'),
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),
if (isLoadingEventTypes)
const Center(child: CircularProgressIndicator())
else
DropdownButtonFormField<String>(
value: selectedEventTypeId,
items: eventTypes
.map((type) => DropdownMenuItem<String>(
value: type.id,
child: Text(type.name),
))
.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),
_buildDateTimeRow(context),
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(),
),
],
);
}
Widget _buildSectionTitle(String title) {
return Padding(
padding: const EdgeInsets.only(top: 0.0, bottom: 4.0),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
title,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
),
);
}
Widget _buildDateTimeRow(BuildContext context) {
return Row(
children: [
Expanded(
child: GestureDetector(
onTap: () => _selectStartDateTime(context),
child: AbsorbPointer(
child: TextFormField(
readOnly: true,
decoration: const InputDecoration(
labelText: 'Début',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.calendar_today),
suffixIcon: 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 : () => _selectEndDateTime(context),
child: AbsorbPointer(
child: TextFormField(
readOnly: true,
decoration: const InputDecoration(
labelText: 'Fin',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.calendar_today),
suffixIcon: 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,
),
),
),
),
],
);
}
Future<void> _selectStartDateTime(BuildContext context) 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) {
final newDateTime = DateTime(
picked.year,
picked.month,
picked.day,
time.hour,
time.minute,
);
onStartDateTimeChanged(newDateTime);
}
}
}
Future<void> _selectEndDateTime(BuildContext context) 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) {
final newDateTime = DateTime(
picked.year,
picked.month,
picked.day,
time.hour,
time.minute,
);
onEndDateTimeChanged(newDateTime);
}
}
}
}

View File

@@ -0,0 +1,123 @@
import 'package:flutter/material.dart';
import 'package:em2rp/views/widgets/inputs/int_stepper_field.dart';
class EventDetailsSection extends StatefulWidget {
final TextEditingController descriptionController;
final TextEditingController installationController;
final TextEditingController disassemblyController;
final TextEditingController addressController;
final bool isMobile;
final VoidCallback onAnyFieldChanged;
const EventDetailsSection({
super.key,
required this.descriptionController,
required this.installationController,
required this.disassemblyController,
required this.addressController,
required this.isMobile,
required this.onAnyFieldChanged,
});
@override
State<EventDetailsSection> createState() => _EventDetailsSectionState();
}
class _EventDetailsSectionState extends State<EventDetailsSection> {
int _descriptionMaxLines = 3;
@override
void initState() {
super.initState();
widget.descriptionController.addListener(_handleDescriptionChange);
}
@override
void dispose() {
widget.descriptionController.removeListener(_handleDescriptionChange);
super.dispose();
}
void _handleDescriptionChange() {
final lines = '\n'.allMatches(widget.descriptionController.text).length + 1;
setState(() {
_descriptionMaxLines = lines.clamp(3, 6);
});
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildSectionTitle('Détails'),
AnimatedContainer(
duration: const Duration(milliseconds: 200),
constraints: BoxConstraints(
minHeight: 48,
maxHeight: widget.isMobile ? 48.0 * 20 : 48.0 * 10,
),
child: TextFormField(
controller: widget.descriptionController,
minLines: 1,
maxLines: _descriptionMaxLines > (widget.isMobile ? 20 : 10)
? (widget.isMobile ? 20 : 10)
: _descriptionMaxLines,
decoration: const InputDecoration(
labelText: 'Description',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.description),
),
onChanged: (_) => widget.onAnyFieldChanged(),
),
),
const SizedBox(height: 20),
Row(
children: [
Expanded(
child: IntStepperField(
label: 'Installation (h)',
controller: widget.installationController,
min: 0,
max: 99,
),
),
const SizedBox(width: 16),
Expanded(
child: IntStepperField(
label: 'Démontage (h)',
controller: widget.disassemblyController,
min: 0,
max: 99,
),
),
],
),
_buildSectionTitle('Adresse'),
TextFormField(
controller: widget.addressController,
decoration: const InputDecoration(
labelText: 'Adresse',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.location_on),
),
validator: (v) => v == null || v.isEmpty ? 'Champ requis' : null,
onChanged: (_) => widget.onAnyFieldChanged(),
),
],
);
}
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),
),
),
);
}
}

View File

@@ -0,0 +1,64 @@
import 'package:flutter/material.dart';
class EventFormActions extends StatelessWidget {
final bool isLoading;
final bool isEditMode;
final VoidCallback onCancel;
final VoidCallback onSubmit;
final VoidCallback? onSetConfirmed;
const EventFormActions({
super.key,
required this.isLoading,
required this.isEditMode,
required this.onCancel,
required this.onSubmit,
this.onSetConfirmed,
});
@override
Widget build(BuildContext context) {
return Column(
children: [
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: isLoading ? null : onCancel,
child: const Text('Annuler'),
),
const SizedBox(width: 8),
ElevatedButton.icon(
icon: const Icon(Icons.check),
onPressed: isLoading ? null : onSubmit,
label: isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(isEditMode ? 'Enregistrer' : 'Créer'),
),
],
),
if (!isEditMode && onSetConfirmed != null)
Center(
child: Padding(
padding: const EdgeInsets.only(top: 16.0),
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: onSetConfirmed,
),
),
),
],
);
}
}

View File

@@ -0,0 +1,130 @@
import 'package:flutter/material.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/inputs/dropzone_upload_widget.dart';
import 'package:file_picker/file_picker.dart';
import 'package:firebase_storage/firebase_storage.dart';
class EventStaffAndDocumentsSection extends StatelessWidget {
final List<UserModel> allUsers;
final List<String> selectedUserIds;
final Function(List<String>) onUserSelectionChanged;
final bool isLoadingUsers;
final List<Map<String, String>> uploadedFiles;
final Function(List<Map<String, String>>) onFilesChanged;
final bool isLoading;
final String? error;
final String? success;
final bool isMobile;
final VoidCallback? onPickAndUploadFiles;
const EventStaffAndDocumentsSection({
super.key,
required this.allUsers,
required this.selectedUserIds,
required this.onUserSelectionChanged,
required this.isLoadingUsers,
required this.uploadedFiles,
required this.onFilesChanged,
required this.isLoading,
this.error,
this.success,
required this.isMobile,
this.onPickAndUploadFiles,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildSectionTitle('Personnel'),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: UserMultiSelectWidget(
allUsers: allUsers,
selectedUserIds: selectedUserIds,
onChanged: onUserSelectionChanged,
isLoading: isLoadingUsers,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionTitle('Documents'),
if (isMobile)
_buildMobileDocumentUpload()
else
DropzoneUploadWidget(
uploadedFiles: uploadedFiles,
onFilesChanged: onFilesChanged,
isLoading: isLoading,
error: error,
success: success,
),
],
),
),
],
),
],
);
}
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),
),
),
);
}
Widget _buildMobileDocumentUpload() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ElevatedButton.icon(
icon: const Icon(Icons.attach_file),
label: const Text('Ajouter un fichier'),
onPressed: isLoading ? null : onPickAndUploadFiles,
),
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
: () {
final newFiles = List<Map<String, String>>.from(uploadedFiles);
newFiles.remove(file);
onFilesChanged(newFiles);
},
),
)),
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)),
),
],
);
}
}

View File

@@ -49,16 +49,14 @@ class _DropzoneUploadWidgetState extends State<DropzoneUploadWidget> {
for (final file in files) { for (final file in files) {
final name = await _dropzoneController!.getFilename(file); final name = await _dropzoneController!.getFilename(file);
final bytes = await _dropzoneController!.getFileData(file); final bytes = await _dropzoneController!.getFileData(file);
if (bytes != null) { final ref = FirebaseStorage.instance.ref().child(
final ref = FirebaseStorage.instance.ref().child( 'events/temp/${DateTime.now().millisecondsSinceEpoch}_$name');
'events/temp/${DateTime.now().millisecondsSinceEpoch}_$name'); final uploadTask = await ref.putData(bytes);
final uploadTask = await ref.putData(bytes); final url = await uploadTask.ref.getDownloadURL();
final url = await uploadTask.ref.getDownloadURL(); if (!newFiles.any((f) => f['name'] == name && f['url'] == url)) {
if (!newFiles.any((f) => f['name'] == name && f['url'] == url)) { newFiles.add({'name': name, 'url': url});
newFiles.add({'name': name, 'url': url});
}
} }
} }
widget.onFilesChanged(newFiles); widget.onFilesChanged(newFiles);
setState(() { setState(() {
_success = "Fichier(s) ajouté(s) !"; _success = "Fichier(s) ajouté(s) !";
@@ -233,7 +231,7 @@ class _DropzoneUploadWidgetState extends State<DropzoneUploadWidget> {
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
dense: true, dense: true,
); );
}).toList(), }),
SizedBox( SizedBox(
width: 160, width: 160,
child: ElevatedButton.icon( child: ElevatedButton.icon(

View File

@@ -27,7 +27,7 @@ class OptionSelectorWidget extends StatefulWidget {
class _OptionSelectorWidgetState extends State<OptionSelectorWidget> { class _OptionSelectorWidgetState extends State<OptionSelectorWidget> {
List<EventOption> _allOptions = []; List<EventOption> _allOptions = [];
bool _loading = true; bool _loading = true;
String _search = ''; final String _search = '';
final List<String> _eventTypes = ['Bal', 'Mariage', 'Anniversaire']; final List<String> _eventTypes = ['Bal', 'Mariage', 'Anniversaire'];
@override @override
@@ -201,8 +201,7 @@ class _OptionPickerDialogState extends State<_OptionPickerDialog> {
final opt = filtered[i]; final opt = filtered[i];
return ListTile( return ListTile(
title: Text(opt.name), title: Text(opt.name),
subtitle: Text(opt.details + subtitle: Text('${opt.details}\nFourchette: ${opt.valMin}€ ~ ${opt.valMax}'),
'\nFourchette: ${opt.valMin}€ ~ ${opt.valMax}'),
onTap: () async { onTap: () async {
final min = opt.valMin; final min = opt.valMin;
final max = opt.valMax; final max = opt.valMax;
@@ -304,7 +303,7 @@ class _CreateOptionDialogState extends State<_CreateOptionDialog> {
final _detailsController = TextEditingController(); final _detailsController = TextEditingController();
final _minPriceController = TextEditingController(); final _minPriceController = TextEditingController();
final _maxPriceController = TextEditingController(); final _maxPriceController = TextEditingController();
List<String> _selectedTypes = []; final List<String> _selectedTypes = [];
final List<String> _allTypes = ['Bal', 'Mariage', 'Anniversaire']; final List<String> _allTypes = ['Bal', 'Mariage', 'Anniversaire'];
String? _error; String? _error;
bool _checkingName = false; bool _checkingName = false;

View File

@@ -7,7 +7,6 @@ import 'package:flutter/material.dart';
import 'package:em2rp/views/widgets/image/profile_picture.dart'; import 'package:em2rp/views/widgets/image/profile_picture.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:em2rp/utils/permission_gate.dart'; import 'package:em2rp/utils/permission_gate.dart';
import 'package:em2rp/models/role_model.dart';
class MainDrawer extends StatelessWidget { class MainDrawer extends StatelessWidget {
final String currentPage; final String currentPage;

View File

@@ -129,7 +129,7 @@ class _EditUserDialogState extends State<EditUserDialog> {
isLoadingRoles isLoadingRoles
? const CircularProgressIndicator() ? const CircularProgressIndicator()
: DropdownButtonFormField<String>( : DropdownButtonFormField<String>(
value: selectedRoleId, initialValue: selectedRoleId,
decoration: _buildInputDecoration( decoration: _buildInputDecoration(
'Rôle', Icons.admin_panel_settings_outlined), 'Rôle', Icons.admin_panel_settings_outlined),
items: availableRoles.map((role) { items: availableRoles.map((role) {

View File

@@ -38,7 +38,6 @@ class _UserMultiSelect extends StatefulWidget {
final ValueChanged<List<String>> onChanged; final ValueChanged<List<String>> onChanged;
const _UserMultiSelect({ const _UserMultiSelect({
super.key,
required this.allUsers, required this.allUsers,
required this.selectedUserIds, required this.selectedUserIds,
required this.onChanged, required this.onChanged,

View File

@@ -20,7 +20,7 @@ dependencies:
universal_io: ^2.2.2 universal_io: ^2.2.2
cupertino_icons: ^1.0.2 cupertino_icons: ^1.0.2
table_calendar: ^3.0.9 table_calendar: ^3.0.9
intl: ^0.19.0 intl: ^0.20.2
google_maps_flutter: ^2.5.0 google_maps_flutter: ^2.5.0
permission_handler: ^12.0.0+1 permission_handler: ^12.0.0+1
geolocator: ^14.0.1 geolocator: ^14.0.1
@@ -55,6 +55,7 @@ dependencies:
flutter_localizations: flutter_localizations:
sdk: flutter sdk: flutter
path: any
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter

View File

@@ -27,7 +27,7 @@
<link rel="apple-touch-icon" href="icons/Icon-192.png"> <link rel="apple-touch-icon" href="icons/Icon-192.png">
<!-- Favicon --> <!-- Favicon -->
<link rel="icon" type="image/png" href="favicon.png"/> <link rel="icon" type="image/png" href="../assets/EM2_NsurB.jpg"/>
<title>em2rp</title> <title>em2rp</title>
<link rel="manifest" href="manifest.json"> <link rel="manifest" href="manifest.json">