refactor: Remplacement de l'accès direct à Firestore par des Cloud Functions

Migration complète du backend pour utiliser des Cloud Functions comme couche API sécurisée, en remplacement des appels directs à Firestore depuis le client.

**Backend (Cloud Functions):**
- **Centralisation CORS :** Ajout d'un middleware `withCors` et d'une configuration `httpOptions` pour gérer uniformément les en-têtes CORS et les requêtes `OPTIONS` sur toutes les fonctions.
- **Nouvelles Fonctions de Lecture (GET) :**
    - `getEquipments`, `getContainers`, `getEvents`, `getUsers`, `getOptions`, `getEventTypes`, `getRoles`, `getMaintenances`, `getAlerts`.
    - Ces fonctions gèrent les permissions côté serveur, masquant les données sensibles (ex: prix des équipements) pour les utilisateurs non-autorisés.
    - `getEvents` retourne également une map des utilisateurs (`usersMap`) pour optimiser le chargement des données de la main d'œuvre.
- **Nouvelle Fonction de Recherche :**
    - `getContainersByEquipment` : Endpoint dédié pour trouver efficacement tous les containers qui contiennent un équipement spécifique.
- **Nouvelles Fonctions d'Écriture (CRUD) :**
    - Fonctions CRUD complètes pour `eventTypes` (`create`, `update`, `delete`), incluant la validation (unicité du nom, vérification des événements futurs avant suppression).
- **Mise à jour de Fonctions Existantes :**
    - Toutes les fonctions CRUD existantes (`create/update/deleteEquipment`, `create/update/deleteContainer`, etc.) sont wrappées avec le nouveau gestionnaire CORS.

**Frontend (Flutter):**
- **Introduction du `DataService` :** Nouveau service centralisant tous les appels aux Cloud Functions, servant d'intermédiaire entre l'UI/Providers et l'API.
- **Refactorisation des Providers :**
    - `EquipmentProvider`, `ContainerProvider`, `EventProvider`, `UsersProvider`, `MaintenanceProvider` et `AlertProvider` ont été refactorisés pour utiliser le `DataService` au lieu d'accéder directement à Firestore.
    - Les `Stream` Firestore sont remplacés par des chargements de données via des méthodes `Future` (`loadEquipments`, `loadEvents`, etc.).
- **Gestion des Relations Équipement-Container :**
    - Le modèle `EquipmentModel` ne stocke plus `parentBoxIds`.
    - La relation est maintenant gérée par le `ContainerModel` qui contient `equipmentIds`.
    - Le `ContainerEquipmentService` est introduit pour utiliser la nouvelle fonction `getContainersByEquipment`.
    - L'affichage des boîtes parentes (`EquipmentParentContainers`) et le formulaire d'équipement (`EquipmentFormPage`) ont été mis à jour pour refléter ce nouveau modèle de données, synchronisant les ajouts/suppressions d'équipements dans les containers.
- **Amélioration de l'UI :**
    - Nouveau widget `ParentBoxesSelector` pour une sélection améliorée et visuelle des boîtes parentes dans le formulaire d'équipement.
    - Refonte visuelle de `EquipmentParentContainers` pour une meilleure présentation.
This commit is contained in:
ElPoyo
2026-01-12 20:38:46 +01:00
parent 13a890606d
commit f38d75362c
46 changed files with 3367 additions and 1510 deletions

View File

@@ -77,7 +77,7 @@ class EventDetails extends StatelessWidget {
EventDetailsDescription(event: event),
EventDetailsDocuments(documents: event.documents),
const SizedBox(height: 16),
EventDetailsEquipe(workforce: event.workforce),
EventDetailsEquipe(event: event),
],
),
),

View File

@@ -1,19 +1,21 @@
import 'package:flutter/material.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:provider/provider.dart';
import 'package:em2rp/models/user_model.dart';
import 'package:em2rp/models/event_model.dart';
import 'package:em2rp/providers/event_provider.dart';
import 'package:em2rp/views/widgets/user_management/user_multi_select_widget.dart';
class EventDetailsEquipe extends StatelessWidget {
final List workforce;
final EventModel event;
const EventDetailsEquipe({
super.key,
required this.workforce,
required this.event,
});
@override
Widget build(BuildContext context) {
if (workforce.isEmpty) {
if (event.workforce.isEmpty) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -33,105 +35,48 @@ class EventDetailsEquipe extends StatelessWidget {
);
}
return FutureBuilder<List<UserModel>>(
future: _fetchUsers(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Equipe',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: Colors.black,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Center(child: CircularProgressIndicator()),
),
],
);
}
// Récupérer les utilisateurs depuis le cache du provider
final eventProvider = Provider.of<EventProvider>(context, listen: false);
final workforceUsers = eventProvider.getWorkforceUsers(event);
if (snapshot.hasError) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Equipe',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: Colors.black,
fontWeight: FontWeight.bold,
),
// Convertir en UserModel
final users = workforceUsers.map((userData) {
return UserModel(
uid: userData['uid'] ?? '',
firstName: userData['firstName'] ?? '',
lastName: userData['lastName'] ?? '',
email: userData['email'] ?? '',
phoneNumber: userData['phoneNumber'] ?? '',
profilePhotoUrl: userData['profilePhotoUrl'] ?? '',
role: '', // Pas besoin du rôle pour l'affichage
);
}).toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Equipe',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: Colors.black,
fontWeight: FontWeight.bold,
),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Text(
snapshot.error.toString().contains('permission-denied')
? "Vous n'avez pas la permission de voir tous les membres de l'équipe."
: "Erreur lors du chargement de l'équipe : ${snapshot.error}",
style: const TextStyle(color: Colors.red),
),
const SizedBox(height: 8),
if (users.isEmpty)
Text(
'Aucun membre assigné.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.orange[700],
),
),
],
);
}
final users = snapshot.data ?? [];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Equipe',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: Colors.black,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
if (users.isEmpty)
Text(
'Aucun membre assigné ou erreur de chargement.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.orange[700],
),
),
if (users.isNotEmpty)
UserChipsList(
users: users,
showRemove: false,
),
],
);
},
),
if (users.isNotEmpty)
UserChipsList(
users: users,
showRemove: false,
),
],
);
}
Future<List<UserModel>> _fetchUsers() async {
final firestore = FirebaseFirestore.instance;
List<UserModel> users = [];
for (int i = 0; i < workforce.length; i++) {
final ref = workforce[i];
try {
if (ref is DocumentReference) {
final doc = await firestore.doc(ref.path).get();
if (doc.exists) {
final userData = doc.data() as Map<String, dynamic>;
users.add(UserModel.fromMap(userData, doc.id));
}
}
} catch (e) {
// Log silencieux des erreurs individuelles
debugPrint('Error fetching user $i: $e');
}
}
return users;
}
}

View File

@@ -1,11 +1,12 @@
import 'package:flutter/material.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:em2rp/models/event_model.dart';
import 'package:em2rp/utils/colors.dart';
import 'package:provider/provider.dart';
import 'package:em2rp/providers/local_user_provider.dart';
import 'package:em2rp/views/event_add_page.dart';
import 'package:em2rp/services/ics_export_service.dart';
import 'package:em2rp/services/data_service.dart';
import 'package:em2rp/services/api_service.dart';
import 'dart:html' as html;
import 'dart:convert';
@@ -53,24 +54,21 @@ class _EventDetailsHeaderState extends State<EventDetailsHeader> {
return;
}
final doc = await FirebaseFirestore.instance
.collection('eventTypes')
.doc(widget.event.eventTypeId)
.get();
// Charger tous les types d'événements via l'API
final dataService = DataService(FirebaseFunctionsApiService());
final eventTypes = await dataService.getEventTypes();
if (doc.exists) {
setState(() {
_eventTypeName = doc.data()?['name'] as String? ?? widget.event.eventTypeId;
_isLoadingEventType = false;
});
} else {
setState(() {
_eventTypeName = widget.event.eventTypeId;
_isLoadingEventType = false;
});
}
// Trouver le type correspondant
final eventType = eventTypes.firstWhere(
(type) => type['id'] == widget.event.eventTypeId,
orElse: () => <String, dynamic>{},
);
setState(() {
_eventTypeName = eventType['name'] as String? ?? widget.event.eventTypeId;
_isLoadingEventType = false;
});
} catch (e) {
print('Erreur lors du chargement du type d\'événement: $e');
setState(() {
_eventTypeName = widget.event.eventTypeId;
_isLoadingEventType = false;

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:provider/provider.dart';
import 'package:em2rp/models/event_model.dart';
import 'package:em2rp/providers/event_provider.dart';
import 'package:em2rp/views/event_preparation_page.dart';
import 'package:em2rp/utils/colors.dart';
@@ -20,30 +21,19 @@ class EventPreparationButtons extends StatefulWidget {
class _EventPreparationButtonsState extends State<EventPreparationButtons> {
@override
Widget build(BuildContext context) {
// Écouter les changements de l'événement en temps réel
return StreamBuilder<DocumentSnapshot>(
stream: FirebaseFirestore.instance
.collection('events')
.doc(widget.event.id)
.snapshots(),
initialData: null,
builder: (context, snapshot) {
// Utiliser l'événement du stream si disponible, sinon l'événement initial
final EventModel currentEvent;
if (snapshot.hasData && snapshot.data != null && snapshot.data!.exists) {
currentEvent = EventModel.fromMap(
snapshot.data!.data() as Map<String, dynamic>,
snapshot.data!.id,
);
} else {
currentEvent = widget.event;
}
// Utiliser le provider pour récupérer l'événement à jour
final eventProvider = context.watch<EventProvider>();
return _buildButtons(context, currentEvent);
},
// Chercher l'événement mis à jour dans le provider
final EventModel currentEvent = eventProvider.events.firstWhere(
(e) => e.id == widget.event.id,
orElse: () => widget.event,
);
return _buildButtons(context, currentEvent);
}
Widget _buildButtons(BuildContext context, EventModel event) {
// Vérifier s'il y a du matériel assigné
final hasMaterial = event.assignedEquipment.isNotEmpty || event.assignedContainers.isNotEmpty;

View File

@@ -1,8 +1,9 @@
import 'package:flutter/material.dart';
import 'package:em2rp/models/event_model.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:provider/provider.dart';
import 'package:em2rp/providers/event_provider.dart';
import 'package:em2rp/services/data_service.dart';
import 'package:em2rp/services/api_service.dart';
class EventStatusButton extends StatefulWidget {
final EventModel event;
@@ -22,30 +23,37 @@ class EventStatusButton extends StatefulWidget {
class _EventStatusButtonState extends State<EventStatusButton> {
bool _loading = false;
final DataService _dataService = DataService(FirebaseFunctionsApiService());
Future<void> _changeStatus(EventStatus newStatus) async {
if (widget.event.status == newStatus) return;
setState(() => _loading = true);
try {
await FirebaseFirestore.instance
.collection('events')
.doc(widget.event.id)
.update({'status': eventStatusToString(newStatus)});
// Mettre à jour via l'API
await _dataService.updateEvent(widget.event.id, {
'status': eventStatusToString(newStatus),
});
final snap = await FirebaseFirestore.instance
.collection('events')
.doc(widget.event.id)
.get();
final updatedEvent = EventModel.fromMap(snap.data()!, widget.event.id);
widget.onSelectEvent(
updatedEvent,
widget.selectedDate ?? updatedEvent.startDateTime,
// Récupérer l'événement mis à jour via l'API
final result = await _dataService.getEvents();
final eventsList = result['events'] as List<dynamic>;
final eventData = eventsList.firstWhere(
(e) => e['id'] == widget.event.id,
orElse: () => <String, dynamic>{},
);
await Provider.of<EventProvider>(context, listen: false)
.updateEvent(updatedEvent);
if (eventData.isNotEmpty) {
final updatedEvent = EventModel.fromMap(eventData, widget.event.id);
widget.onSelectEvent(
updatedEvent,
widget.selectedDate ?? updatedEvent.startDateTime,
);
await Provider.of<EventProvider>(context, listen: false)
.updateEvent(updatedEvent);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(