perf: Implémentation du lazy loading pour le calendrier
Cette mise à jour refactorise en profondeur le chargement des événements sur la page Calendrier pour améliorer drastiquement les performances et la réactivité de l'application, en particulier pour les utilisateurs avec un grand nombre d'événements. Le système de chargement initial de tous les événements est remplacé par un mécanisme de lazy loading qui ne récupère que les données du mois affiché.
**Changements majeurs :**
- **Lazy Loading Côté Client (`EventProvider`) :**
- Une nouvelle méthode `loadMonthEvents` a été introduite pour charger uniquement les événements d'un mois spécifique (`year`, `month`).
- Un cache par mois (`_eventsByMonth`) a été mis en place pour éviter les rechargements inutiles lors de la navigation entre des mois déjà consultés.
- Ajout d'une fonction `preloadAdjacentMonths` qui charge en arrière-plan et silencieusement les mois précédent et suivant, assurant une navigation fluide dans le calendrier.
- **Nouveau Endpoint Backend (`getEventsByMonth`) :**
- Création d'un nouvel endpoint Cloud Function `getEventsByMonth` optimisé pour ne requêter que les événements dans une plage de dates (début et fin du mois).
- La fonction récupère les utilisateurs associés de manière optimisée en parallélisant les requêtes Firestore (Promise.all).
- La limite du nombre d'IDs par requête 'in' a été augmentée de 10 à 30 pour réduire le nombre d'appels à la base de données.
- **Intégration au Calendrier (`CalendarPage`) :**
- La page charge désormais les événements pour le mois courant au démarrage via `_loadCurrentMonthEvents`.
- Lorsqu'un utilisateur change de mois (`onPageChanged`), la page déclenche le chargement des données pour le nouveau mois, avec un préchargement des mois adjacents pour anticiper la navigation.
- Le chargement initial de tous les événements (`_loadEventsAsync`) a été déprécié.
- **Correction de la Séquence de Démarrage (`main.dart`) :**
- L'appel à `_autoLogin` est maintenant enveloppé dans `WidgetsBinding.instance.addPostFrameCallback`. Cela garantit que la navigation ne se produit qu'après le premier rendu de l'interface, évitant ainsi des erreurs potentielles de build/navigation concurrentes et fiabilisant le chargement initial des données utilisateur.
This commit is contained in:
@@ -1663,19 +1663,25 @@ exports.getEvents = onRequest(httpOptions, withCors(async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Récupérer tous les utilisateurs en une seule fois
|
||||
// Récupérer tous les utilisateurs en PARALLÈLE (optimisé)
|
||||
const usersMap = {};
|
||||
if (userIdsSet.size > 0) {
|
||||
const userIds = Array.from(userIdsSet);
|
||||
const batchSize = 30; // Augmenté de 10 à 30 pour réduire le nombre de requêtes
|
||||
|
||||
// Récupérer par batch (Firestore limite à 10 par requête 'in')
|
||||
const batchSize = 10;
|
||||
// Exécuter les requêtes en PARALLÈLE au lieu de séquentiel
|
||||
const batchPromises = [];
|
||||
for (let i = 0; i < userIds.length; i += batchSize) {
|
||||
const batch = userIds.slice(i, i + batchSize);
|
||||
const usersSnapshot = await db.collection('users')
|
||||
.where(admin.firestore.FieldPath.documentId(), 'in', batch)
|
||||
.get();
|
||||
batchPromises.push(
|
||||
db.collection('users')
|
||||
.where(admin.firestore.FieldPath.documentId(), 'in', batch)
|
||||
.get()
|
||||
);
|
||||
}
|
||||
|
||||
const results = await Promise.all(batchPromises);
|
||||
results.forEach(usersSnapshot => {
|
||||
usersSnapshot.docs.forEach(userDoc => {
|
||||
const userData = userDoc.data();
|
||||
// Stocker uniquement les données publiques
|
||||
@@ -1688,7 +1694,7 @@ exports.getEvents = onRequest(httpOptions, withCors(async (req, res) => {
|
||||
profilePhotoUrl: userData.profilePhotoUrl || '',
|
||||
};
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Sérialiser les événements avec workforce comme liste d'UIDs
|
||||
@@ -1726,6 +1732,137 @@ exports.getEvents = onRequest(httpOptions, withCors(async (req, res) => {
|
||||
}
|
||||
}));
|
||||
|
||||
// ============================================================================
|
||||
// EVENTS - Get by month (optimized lazy loading)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Récupère les événements d'un mois spécifique (lazy loading optimisé)
|
||||
* Réduit drastiquement le temps de chargement en ne chargeant que le mois demandé
|
||||
*/
|
||||
exports.getEventsByMonth = onRequest(httpOptions, withCors(async (req, res) => {
|
||||
try {
|
||||
const decodedToken = await auth.authenticateUser(req);
|
||||
const { userId, year, month } = req.body.data || {};
|
||||
|
||||
if (!year || !month) {
|
||||
res.status(400).json({ error: 'year and month are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Fetching events for ${year}-${month}`);
|
||||
|
||||
// Calculer le début et la fin du mois
|
||||
const startOfMonth = admin.firestore.Timestamp.fromDate(
|
||||
new Date(year, month - 1, 1, 0, 0, 0)
|
||||
);
|
||||
const endOfMonth = admin.firestore.Timestamp.fromDate(
|
||||
new Date(year, month, 0, 23, 59, 59)
|
||||
);
|
||||
|
||||
// Vérifier si l'utilisateur peut voir tous les événements
|
||||
const canViewAll = await auth.hasPermission(decodedToken.uid, 'view_all_events');
|
||||
|
||||
let eventsQuery = db.collection('events')
|
||||
.where('StartDateTime', '>=', startOfMonth)
|
||||
.where('StartDateTime', '<=', endOfMonth);
|
||||
|
||||
if (!canViewAll) {
|
||||
// Utilisateur normal : seulement ses événements assignés
|
||||
const userRef = db.collection('users').doc(userId || decodedToken.uid);
|
||||
eventsQuery = eventsQuery.where('workforce', 'array-contains', userRef);
|
||||
}
|
||||
|
||||
const eventsSnapshot = await eventsQuery.get();
|
||||
|
||||
logger.info(`Found ${eventsSnapshot.docs.length} events for ${year}-${month}`);
|
||||
|
||||
// Collecter tous les UIDs utilisateurs uniques
|
||||
const userIdsSet = new Set();
|
||||
|
||||
eventsSnapshot.docs.forEach(doc => {
|
||||
const data = doc.data();
|
||||
if (data.workforce && Array.isArray(data.workforce)) {
|
||||
data.workforce.forEach(userRef => {
|
||||
if (userRef && userRef.id) {
|
||||
userIdsSet.add(userRef.id);
|
||||
} else if (typeof userRef === 'string' && userRef.startsWith('users/')) {
|
||||
userIdsSet.add(userRef.split('/')[1]);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Récupérer tous les utilisateurs en PARALLÈLE (optimisé)
|
||||
const usersMap = {};
|
||||
if (userIdsSet.size > 0) {
|
||||
const userIds = Array.from(userIdsSet);
|
||||
const batchSize = 30; // Limite Firestore augmentée de 10 à 30
|
||||
|
||||
// Exécuter les requêtes en parallèle au lieu de séquentiel
|
||||
const batchPromises = [];
|
||||
for (let i = 0; i < userIds.length; i += batchSize) {
|
||||
const batch = userIds.slice(i, i + batchSize);
|
||||
batchPromises.push(
|
||||
db.collection('users')
|
||||
.where(admin.firestore.FieldPath.documentId(), 'in', batch)
|
||||
.get()
|
||||
);
|
||||
}
|
||||
|
||||
const results = await Promise.all(batchPromises);
|
||||
results.forEach(usersSnapshot => {
|
||||
usersSnapshot.docs.forEach(userDoc => {
|
||||
const userData = userDoc.data();
|
||||
usersMap[userDoc.id] = {
|
||||
uid: userDoc.id,
|
||||
firstName: userData.firstName || '',
|
||||
lastName: userData.lastName || '',
|
||||
email: userData.email || '',
|
||||
phoneNumber: userData.phoneNumber || '',
|
||||
profilePhotoUrl: userData.profilePhotoUrl || '',
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Sérialiser les événements avec workforce comme liste d'UIDs
|
||||
const events = eventsSnapshot.docs.map(doc => {
|
||||
const data = doc.data();
|
||||
|
||||
// Convertir workforce en liste d'UIDs
|
||||
let workforceUids = [];
|
||||
if (data.workforce && Array.isArray(data.workforce)) {
|
||||
workforceUids = data.workforce.map(userRef => {
|
||||
if (userRef && userRef.id) {
|
||||
return userRef.id;
|
||||
} else if (typeof userRef === 'string' && userRef.startsWith('users/')) {
|
||||
return userRef.split('/')[1];
|
||||
}
|
||||
return null;
|
||||
}).filter(uid => uid !== null);
|
||||
}
|
||||
|
||||
return {
|
||||
id: doc.id,
|
||||
...helpers.serializeTimestamps(data),
|
||||
workforce: workforceUids,
|
||||
};
|
||||
});
|
||||
|
||||
logger.info(`Returning ${events.length} events with ${Object.keys(usersMap).length} unique users`);
|
||||
|
||||
res.status(200).json({
|
||||
events,
|
||||
users: usersMap,
|
||||
month: { year, month }
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Error fetching events by month:", error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
}));
|
||||
|
||||
/**
|
||||
* Récupère un événement avec tous les détails (équipements complets + containers avec enfants)
|
||||
* Optimisé pour la page de préparation et l'affichage détaillé
|
||||
|
||||
@@ -200,7 +200,10 @@ class _AutoLoginWrapperState extends State<AutoLoginWrapper> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_autoLogin();
|
||||
// Attendre la fin du premier build avant de naviguer
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_autoLogin();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _autoLogin() async {
|
||||
|
||||
@@ -10,17 +10,21 @@ class EventProvider with ChangeNotifier {
|
||||
List<EventModel> _events = [];
|
||||
bool _isLoading = false;
|
||||
|
||||
List<EventModel> get events => _events;
|
||||
bool get isLoading => _isLoading;
|
||||
|
||||
// Cache des utilisateurs chargés depuis getEvents
|
||||
Map<String, Map<String, dynamic>> _usersCache = {};
|
||||
|
||||
// Cache pour éviter les rechargements inutiles
|
||||
// Cache pour éviter les rechargements inutiles (ancien système)
|
||||
DateTime? _lastLoadTime;
|
||||
String? _lastUserId;
|
||||
bool _lastCanViewAll = false;
|
||||
|
||||
// Nouveau: Cache par mois pour le lazy loading
|
||||
Map<String, List<EventModel>> _eventsByMonth = {}; // "2026-02" => [events]
|
||||
String? _currentMonth; // Mois actuellement affiché
|
||||
|
||||
List<EventModel> get events => _events;
|
||||
bool get isLoading => _isLoading;
|
||||
|
||||
/// Vérifie si les données doivent être rechargées (cache de 30 secondes)
|
||||
bool _shouldReload(String userId, bool canViewAllEvents) {
|
||||
if (_lastLoadTime == null) return true;
|
||||
@@ -98,6 +102,124 @@ class EventProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// Charger les événements d'un mois spécifique (lazy loading optimisé)
|
||||
Future<void> loadMonthEvents(String userId, int year, int month,
|
||||
{bool canViewAllEvents = false, bool forceReload = false, bool silent = false}) async {
|
||||
|
||||
final monthKey = '$year-${month.toString().padLeft(2, '0')}';
|
||||
|
||||
// Vérifier le cache
|
||||
if (!forceReload && _eventsByMonth.containsKey(monthKey)) {
|
||||
print('[EventProvider] Using cached events for $monthKey');
|
||||
|
||||
if (!silent) {
|
||||
_currentMonth = monthKey;
|
||||
_events = _eventsByMonth[monthKey]!;
|
||||
notifyListeners();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!silent) {
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
try {
|
||||
print('[EventProvider] Loading events for month: $monthKey');
|
||||
|
||||
PerformanceMonitor.start('EventProvider.loadMonthEvents_API');
|
||||
final result = await _dataService.getEventsByMonth(
|
||||
userId: userId,
|
||||
year: year,
|
||||
month: month
|
||||
);
|
||||
PerformanceMonitor.end('EventProvider.loadMonthEvents_API');
|
||||
|
||||
final eventsData = result['events'] as List<Map<String, dynamic>>;
|
||||
final usersData = result['users'] as Map<String, dynamic>;
|
||||
|
||||
// Mettre à jour le cache utilisateurs (addAll pour cumuler)
|
||||
_usersCache.addAll(
|
||||
usersData.map((key, value) => MapEntry(key, value as Map<String, dynamic>))
|
||||
);
|
||||
|
||||
print('[EventProvider] Found ${eventsData.length} events for $monthKey');
|
||||
|
||||
PerformanceMonitor.start('EventProvider.parseMonthEvents');
|
||||
List<EventModel> monthEvents = [];
|
||||
int failedCount = 0;
|
||||
|
||||
// Parser les événements
|
||||
for (var eventData in eventsData) {
|
||||
try {
|
||||
final event = EventModel.fromMap(eventData, eventData['id'] as String);
|
||||
monthEvents.add(event);
|
||||
} catch (e) {
|
||||
print('[EventProvider] Failed to parse event ${eventData['id']}: $e');
|
||||
failedCount++;
|
||||
}
|
||||
}
|
||||
PerformanceMonitor.end('EventProvider.parseMonthEvents');
|
||||
|
||||
// Stocker dans le cache par mois
|
||||
_eventsByMonth[monthKey] = monthEvents;
|
||||
|
||||
// Mettre à jour _events et _currentMonth seulement si ce n'est pas un préchargement silencieux
|
||||
if (!silent) {
|
||||
_currentMonth = monthKey;
|
||||
_events = monthEvents;
|
||||
}
|
||||
|
||||
// Mettre à jour les infos de cache global
|
||||
_lastLoadTime = DateTime.now();
|
||||
_lastUserId = userId;
|
||||
_lastCanViewAll = canViewAllEvents;
|
||||
|
||||
print('[EventProvider] Successfully loaded ${monthEvents.length} events for $monthKey (${failedCount} failed)');
|
||||
|
||||
if (!silent) {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
} catch (e) {
|
||||
print('[EventProvider] Error loading month events: $e');
|
||||
if (!silent) {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Précharger les mois adjacents en arrière-plan
|
||||
void preloadAdjacentMonths(String userId, int year, int month,
|
||||
{bool canViewAllEvents = false}) {
|
||||
|
||||
// Mois précédent
|
||||
final prevMonth = month == 1 ? 12 : month - 1;
|
||||
final prevYear = month == 1 ? year - 1 : year;
|
||||
|
||||
// Mois suivant
|
||||
final nextMonth = month == 12 ? 1 : month + 1;
|
||||
final nextYear = month == 12 ? year + 1 : year;
|
||||
|
||||
print('[EventProvider] Preloading adjacent months...');
|
||||
|
||||
// Charger en arrière-plan (sans bloquer l'UI ni notifier)
|
||||
Future.microtask(() async {
|
||||
try {
|
||||
await loadMonthEvents(userId, prevYear, prevMonth,
|
||||
canViewAllEvents: canViewAllEvents, silent: true);
|
||||
await loadMonthEvents(userId, nextYear, nextMonth,
|
||||
canViewAllEvents: canViewAllEvents, silent: true);
|
||||
print('[EventProvider] Adjacent months preloaded successfully');
|
||||
} catch (e) {
|
||||
print('[EventProvider] Error preloading adjacent months: $e');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Recharger les événements (utilise le dernier userId)
|
||||
Future<void> refreshEvents(String userId, {bool canViewAllEvents = false}) async {
|
||||
await loadUserEvents(userId, canViewAllEvents: canViewAllEvents, forceReload: true);
|
||||
|
||||
@@ -196,7 +196,8 @@ class LocalUserProvider with ChangeNotifier {
|
||||
try {
|
||||
UserCredential userCredential = await _auth.signInWithEmailAndPassword(
|
||||
email: email, password: password);
|
||||
await loadUserData();
|
||||
// Note: loadUserData() sera appelé en arrière-plan dans main.dart
|
||||
// pour ne pas bloquer la navigation
|
||||
return userCredential;
|
||||
} catch (e) {
|
||||
throw FirebaseAuthException(code: 'login-failed', message: e.toString());
|
||||
|
||||
@@ -253,6 +253,36 @@ class DataService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère les événements d'un mois spécifique (lazy loading optimisé)
|
||||
Future<Map<String, dynamic>> getEventsByMonth({
|
||||
required String userId,
|
||||
required int year,
|
||||
required int month,
|
||||
}) async {
|
||||
try {
|
||||
print('[DataService] Calling getEventsByMonth for $year-$month');
|
||||
final result = await _apiService.call('getEventsByMonth', {
|
||||
'userId': userId,
|
||||
'year': year,
|
||||
'month': month,
|
||||
});
|
||||
|
||||
// Extraire events et users
|
||||
final events = result['events'] as List<dynamic>? ?? [];
|
||||
final users = result['users'] as Map<String, dynamic>? ?? {};
|
||||
|
||||
print('[DataService] Events loaded for $year-$month: ${events.length} events');
|
||||
|
||||
return {
|
||||
'events': events.map((e) => e as Map<String, dynamic>).toList(),
|
||||
'users': users,
|
||||
};
|
||||
} catch (e) {
|
||||
print('[DataService] Error getting events by month: $e');
|
||||
throw Exception('Erreur lors de la récupération des événements du mois: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère un événement avec tous les détails (équipements complets + containers avec enfants)
|
||||
Future<Map<String, dynamic>> getEventWithDetails(String eventId) async {
|
||||
try {
|
||||
|
||||
@@ -36,11 +36,51 @@ class _CalendarPageState extends State<CalendarPage> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
initializeDateFormatting('fr_FR', null);
|
||||
// Charger les événements de manière asynchrone sans bloquer l'UI
|
||||
_loadEventsAsync();
|
||||
// Charger les événements du mois courant après le premier build
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_loadCurrentMonthEvents();
|
||||
});
|
||||
}
|
||||
|
||||
/// Charge les événements du mois courant avec lazy loading
|
||||
Future<void> _loadCurrentMonthEvents() async {
|
||||
PerformanceMonitor.start('CalendarPage.loadCurrentMonthEvents');
|
||||
|
||||
final localAuthProvider = Provider.of<LocalUserProvider>(context, listen: false);
|
||||
final eventProvider = Provider.of<EventProvider>(context, listen: false);
|
||||
final userId = localAuthProvider.uid;
|
||||
final canViewAllEvents = localAuthProvider.hasPermission('view_all_events');
|
||||
|
||||
if (userId != null) {
|
||||
print('[CalendarPage] Loading events for ${_focusedDay.year}-${_focusedDay.month}');
|
||||
|
||||
await eventProvider.loadMonthEvents(
|
||||
userId,
|
||||
_focusedDay.year,
|
||||
_focusedDay.month,
|
||||
canViewAllEvents: canViewAllEvents,
|
||||
);
|
||||
|
||||
// Précharger les mois adjacents en arrière-plan
|
||||
eventProvider.preloadAdjacentMonths(
|
||||
userId,
|
||||
_focusedDay.year,
|
||||
_focusedDay.month,
|
||||
canViewAllEvents: canViewAllEvents,
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
PerformanceMonitor.start('CalendarPage.selectDefaultEvent');
|
||||
_selectDefaultEvent();
|
||||
PerformanceMonitor.end('CalendarPage.selectDefaultEvent');
|
||||
}
|
||||
}
|
||||
|
||||
PerformanceMonitor.end('CalendarPage.loadCurrentMonthEvents');
|
||||
}
|
||||
|
||||
/// Charge les événements de manière asynchrone et sélectionne l'événement approprié
|
||||
/// DEPRECATED: Utiliser _loadCurrentMonthEvents à la place
|
||||
Future<void> _loadEventsAsync() async {
|
||||
PerformanceMonitor.start('CalendarPage.loadEventsAsync');
|
||||
await _loadEvents();
|
||||
@@ -157,6 +197,12 @@ class _CalendarPageState extends State<CalendarPage> {
|
||||
// Appliquer le filtre utilisateur si actif
|
||||
final filteredEvents = _getFilteredEvents(eventProvider.events);
|
||||
|
||||
// Debug logs
|
||||
print('[CalendarPage.build] Total events: ${eventProvider.events.length}, Filtered: ${filteredEvents.length}');
|
||||
if (eventProvider.events.isNotEmpty) {
|
||||
print('[CalendarPage.build] First event: ${eventProvider.events.first.name} at ${eventProvider.events.first.startDateTime}');
|
||||
}
|
||||
|
||||
if (eventProvider.isLoading) {
|
||||
return const Scaffold(
|
||||
body: Center(
|
||||
@@ -659,9 +705,19 @@ class _CalendarPageState extends State<CalendarPage> {
|
||||
});
|
||||
},
|
||||
onPageChanged: (focusedDay) {
|
||||
// Détecter si on a changé de mois
|
||||
final monthChanged = focusedDay.year != _focusedDay.year ||
|
||||
focusedDay.month != _focusedDay.month;
|
||||
|
||||
setState(() {
|
||||
_focusedDay = focusedDay;
|
||||
});
|
||||
|
||||
// Charger les événements du nouveau mois si nécessaire
|
||||
if (monthChanged) {
|
||||
print('[CalendarPage] Month changed to ${focusedDay.year}-${focusedDay.month}');
|
||||
_loadCurrentMonthEvents();
|
||||
}
|
||||
},
|
||||
onEventSelected: (event) {
|
||||
setState(() {
|
||||
|
||||
Reference in New Issue
Block a user