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:
ElPoyo
2026-02-09 11:20:08 +01:00
parent 8cd4854924
commit 7cbb48e679
6 changed files with 364 additions and 15 deletions

View File

@@ -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')
batchPromises.push(
db.collection('users')
.where(admin.firestore.FieldPath.documentId(), 'in', batch)
.get();
.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é

View File

@@ -200,7 +200,10 @@ class _AutoLoginWrapperState extends State<AutoLoginWrapper> {
@override
void initState() {
super.initState();
// Attendre la fin du premier build avant de naviguer
WidgetsBinding.instance.addPostFrameCallback((_) {
_autoLogin();
});
}
Future<void> _autoLogin() async {

View File

@@ -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);

View File

@@ -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());

View File

@@ -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 {

View File

@@ -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(() {