Cette mise à jour majeure refactorise entièrement la gestion des utilisateurs pour la faire passer par des Cloud Functions sécurisées et migre une part importante de la logique métier (gestion des événements, maintenances, containers) du client vers le backend.
**Gestion des Utilisateurs (Backend & Frontend):**
- **Nouvelle fonction `createUserWithInvite` :**
- Crée l'utilisateur dans Firebase Auth avec un mot de passe temporaire.
- Crée le document utilisateur correspondant dans Firestore.
- Envoie automatiquement un e-mail de réinitialisation de mot de passe (via l'API REST de Firebase et `axios`) pour que l'utilisateur définisse son propre mot de passe, améliorant la sécurité et l'expérience d'intégration.
- **Refactorisation de `updateUser` et `deleteUser` :**
- Les anciennes fonctions `onCall` sont remplacées par des fonctions `onRequest` (HTTP) standards, alignées avec le reste de l'API.
- La logique de suppression gère désormais la suppression dans Auth et Firestore.
- **Réinitialisation de Mot de Passe (UI) :**
- Ajout d'un bouton "Réinitialiser le mot de passe" sur la carte utilisateur, permettant aux administrateurs d'envoyer un e-mail de réinitialisation à n'importe quel utilisateur.
- **Amélioration de l'UI :**
- Boîte de dialogue de confirmation améliorée pour la suppression d'un utilisateur.
- Notifications (Snackbars) pour les opérations de création, suppression et réinitialisation de mot de passe.
**Migration de la Logique Métier vers les Cloud Functions:**
- **Gestion de la Préparation d'Événements :**
- Migration complète de la logique de validation des étapes (préparation, chargement, déchargement, retour) du client vers de nouvelles Cloud Functions (`validateEquipmentPreparation`, `validateAllLoading`, etc.).
- Le backend gère désormais la mise à jour des statuts de l'événement (`inProgress`, `completed`) et des équipements (`inUse`, `available`).
- Le code frontend (`EventPreparationService`) a été simplifié pour appeler ces nouvelles fonctions au lieu d'effectuer des écritures directes sur Firestore.
- **Création de Maintenance :**
- La fonction `createMaintenance` gère maintenant la mise à jour des équipements associés (`maintenanceIds`) et la création d'alertes (`maintenanceDue`) si une maintenance est prévue prochainement. La logique client a été supprimée.
- **Suppression de Container :**
- La fonction `deleteContainer` a été améliorée pour nettoyer automatiquement les références (`parentBoxIds`) dans tous les équipements contenus avant de supprimer le container.
**Refactorisation et Corrections (Backend & Frontend) :**
- **Fiabilisation des Appels API (Frontend) :**
- Le `ApiService` a été renforcé pour convertir de manière plus robuste les données (notamment les `Map` de type `_JsonMap`) en JSON standard avant de les envoyer aux Cloud Functions, évitant ainsi des erreurs de sérialisation.
- **Correction des Références (Backend) :**
- La fonction `updateUser` convertit correctement les `roleId` (string) en `DocumentReference` Firestore.
- Sécurisation de la vérification de l'assignation d'un utilisateur à un événement (`workforce`) pour éviter les erreurs sur des références nulles.
- **Dépendance (Backend) :**
- Ajout de la librairie `axios` pour effectuer des appels à l'API REST de Firebase.
491 lines
18 KiB
Dart
491 lines
18 KiB
Dart
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:em2rp/services/data_service.dart';
|
|
import 'package:em2rp/services/api_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();
|
|
final TextEditingController jaugeController = TextEditingController();
|
|
final TextEditingController contactEmailController = TextEditingController();
|
|
final TextEditingController contactPhoneController = TextEditingController();
|
|
|
|
// State variables
|
|
DateTime? _startDateTime;
|
|
DateTime? _endDateTime;
|
|
bool _isLoading = false;
|
|
String? _error;
|
|
String? _success;
|
|
String? _selectedEventTypeId;
|
|
List<EventTypeModel> _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;
|
|
List<EventEquipment> _assignedEquipment = [];
|
|
List<String> _assignedContainers = [];
|
|
|
|
// 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<EventTypeModel> 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;
|
|
List<EventEquipment> get assignedEquipment => _assignedEquipment;
|
|
List<String> get assignedContainers => _assignedContainers;
|
|
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);
|
|
jaugeController.addListener(_onAnyFieldChanged);
|
|
contactEmailController.addListener(_onAnyFieldChanged);
|
|
contactPhoneController.addListener(_onAnyFieldChanged);
|
|
}
|
|
|
|
void _onAnyFieldChanged() {
|
|
if (!_formChanged) {
|
|
_formChanged = true;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
Future<void> initialize({EventModel? existingEvent, DateTime? selectedDate}) async {
|
|
await Future.wait([
|
|
_fetchUsers(),
|
|
_fetchEventTypes(),
|
|
]);
|
|
|
|
if (existingEvent != null) {
|
|
_populateFromEvent(existingEvent);
|
|
} else {
|
|
_selectedStatus = EventStatus.waitingForApproval;
|
|
|
|
// Préremplir les dates si une date est sélectionnée dans le calendrier
|
|
if (selectedDate != null) {
|
|
// Date de début : selectedDate à 20h00
|
|
_startDateTime = DateTime(
|
|
selectedDate.year,
|
|
selectedDate.month,
|
|
selectedDate.day,
|
|
20,
|
|
0,
|
|
);
|
|
// Date de fin : selectedDate + 4 heures
|
|
_endDateTime = _startDateTime!.add(const Duration(hours: 4));
|
|
}
|
|
}
|
|
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;
|
|
jaugeController.text = event.jauge?.toString() ?? '';
|
|
contactEmailController.text = event.contactEmail ?? '';
|
|
contactPhoneController.text = event.contactPhone ?? '';
|
|
_startDateTime = event.startDateTime;
|
|
_endDateTime = event.endDateTime;
|
|
_assignedEquipment = List<EventEquipment>.from(event.assignedEquipment);
|
|
_assignedContainers = List<String>.from(event.assignedContainers);
|
|
_selectedEventTypeId = event.eventTypeId.isNotEmpty ? event.eventTypeId : null;
|
|
|
|
// Gérer workforce qui peut contenir String ou DocumentReference
|
|
_selectedUserIds = event.workforce.map((ref) {
|
|
if (ref is String) return ref;
|
|
if (ref is DocumentReference) return ref.id;
|
|
return '';
|
|
}).where((id) => id.isNotEmpty).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 EventTypeModel? oldEventType = oldEventTypeIndex != -1 ? _eventTypes[oldEventTypeIndex] : null;
|
|
|
|
_selectedEventTypeId = newTypeId;
|
|
|
|
if (newTypeId != null) {
|
|
final selectedType = _eventTypes.firstWhere((et) => et.id == newTypeId);
|
|
|
|
// Utiliser le prix par défaut du type d'événement
|
|
final defaultPrice = selectedType.defaultPrice;
|
|
final currentPrice = double.tryParse(basePriceController.text.replaceAll(',', '.'));
|
|
final oldDefaultPrice = oldEventType?.defaultPrice;
|
|
|
|
// Mettre à jour le prix si le champ est vide ou si c'était l'ancien prix par défaut
|
|
if (basePriceController.text.isEmpty ||
|
|
(currentPrice != null && oldDefaultPrice != null && currentPrice == oldDefaultPrice)) {
|
|
basePriceController.text = defaultPrice.toStringAsFixed(2);
|
|
}
|
|
|
|
// Filtrer les options qui ne sont plus compatibles avec le nouveau type
|
|
final before = _selectedOptions.length;
|
|
_selectedOptions.removeWhere((opt) {
|
|
// Vérifier si cette option est compatible avec le type d'événement sélectionné
|
|
final optionEventTypes = opt['eventTypes'] as List<dynamic>? ?? [];
|
|
return !optionEventTypes.contains(selectedType.id);
|
|
});
|
|
|
|
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();
|
|
}
|
|
|
|
void setAssignedEquipment(List<EventEquipment> equipment, List<String> containers) {
|
|
_assignedEquipment = equipment;
|
|
_assignedContainers = containers;
|
|
_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
|
|
? null // Les références Firestore ne sont plus nécessaires, l'ID suffit
|
|
: 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.doc('users/$id'))
|
|
.toList(),
|
|
latitude: existingEvent.latitude,
|
|
longitude: existingEvent.longitude,
|
|
documents: finalDocuments,
|
|
options: _selectedOptions,
|
|
status: _selectedStatus,
|
|
jauge: jaugeController.text.isNotEmpty ? int.tryParse(jaugeController.text) : null,
|
|
contactEmail: contactEmailController.text.isNotEmpty ? contactEmailController.text.trim() : null,
|
|
contactPhone: contactPhoneController.text.isNotEmpty ? contactPhoneController.text.trim() : null,
|
|
assignedEquipment: _assignedEquipment,
|
|
assignedContainers: _assignedContainers,
|
|
preparationStatus: existingEvent.preparationStatus,
|
|
returnStatus: existingEvent.returnStatus,
|
|
);
|
|
|
|
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.doc('users/$id'))
|
|
.toList(),
|
|
latitude: 0.0,
|
|
longitude: 0.0,
|
|
documents: _uploadedFiles,
|
|
options: _selectedOptions,
|
|
status: _selectedStatus,
|
|
jauge: jaugeController.text.isNotEmpty ? int.tryParse(jaugeController.text) : null,
|
|
contactEmail: contactEmailController.text.isNotEmpty ? contactEmailController.text.trim() : null,
|
|
contactPhone: contactPhoneController.text.isNotEmpty ? contactPhoneController.text.trim() : null,
|
|
assignedContainers: _assignedContainers,
|
|
assignedEquipment: _assignedEquipment,
|
|
);
|
|
|
|
final eventId = await EventFormService.createEvent(newEvent);
|
|
|
|
// Déplacer et mettre à jour les fichiers uniquement s'il y en a
|
|
if (_uploadedFiles.isNotEmpty) {
|
|
final newFiles = await EventFormService.moveFilesToEvent(_uploadedFiles, eventId);
|
|
if (newFiles.isNotEmpty) {
|
|
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();
|
|
}
|
|
}
|
|
|
|
Future<bool> deleteEvent(BuildContext context, String eventId) async {
|
|
_isLoading = true;
|
|
_error = null;
|
|
_success = null;
|
|
notifyListeners();
|
|
|
|
try {
|
|
// Supprimer l'événement via l'API
|
|
final dataService = DataService(FirebaseFunctionsApiService());
|
|
await dataService.deleteEvent(eventId);
|
|
|
|
// Recharger la liste des événements
|
|
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 supprimé avec succès !";
|
|
notifyListeners();
|
|
return true;
|
|
} catch (e) {
|
|
_error = "Erreur lors de la suppression : $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();
|
|
jaugeController.dispose();
|
|
contactEmailController.dispose();
|
|
contactPhoneController.dispose();
|
|
super.dispose();
|
|
}
|
|
}
|