Files
EM2_ERP/em2rp/lib/views/calendar_page.dart
ElPoyo 7cbb48e679 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.
2026-02-09 11:20:08 +01:00

731 lines
27 KiB
Dart

import 'package:em2rp/providers/local_user_provider.dart';
import 'package:em2rp/providers/event_provider.dart';
import 'package:em2rp/utils/performance_monitor.dart';
import 'package:flutter/material.dart';
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
import 'package:em2rp/views/widgets/nav/main_drawer.dart';
import 'package:provider/provider.dart';
import 'package:table_calendar/table_calendar.dart';
import 'package:em2rp/models/event_model.dart';
import 'package:em2rp/views/widgets/calendar_widgets/event_details.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'package:em2rp/views/widgets/calendar_widgets/month_view.dart';
import 'package:em2rp/views/widgets/calendar_widgets/week_view.dart';
import 'package:em2rp/views/event_add_page.dart';
import 'package:em2rp/views/widgets/calendar_widgets/mobile_calendar_view.dart';
import 'package:em2rp/views/widgets/calendar_widgets/user_filter_dropdown.dart';
import 'package:em2rp/utils/colors.dart';
class CalendarPage extends StatefulWidget {
const CalendarPage({super.key});
@override
State<CalendarPage> createState() => _CalendarPageState();
}
class _CalendarPageState extends State<CalendarPage> {
CalendarFormat _calendarFormat = CalendarFormat.month;
DateTime _focusedDay = DateTime.now();
DateTime? _selectedDay;
EventModel? _selectedEvent;
bool _calendarCollapsed = false;
int _selectedEventIndex = 0;
String? _selectedUserId; // Filtre par utilisateur (null = tous les événements)
@override
void initState() {
super.initState();
initializeDateFormatting('fr_FR', null);
// 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();
// Sélectionner l'événement approprié après le chargement
if (mounted) {
PerformanceMonitor.start('CalendarPage.selectDefaultEvent');
_selectDefaultEvent();
PerformanceMonitor.end('CalendarPage.selectDefaultEvent');
}
PerformanceMonitor.end('CalendarPage.loadEventsAsync');
}
/// Sélectionne automatiquement l'événement le plus proche de maintenant
void _selectDefaultEvent() {
final eventProvider = Provider.of<EventProvider>(context, listen: false);
final events = eventProvider.events;
if (events.isEmpty) return;
final now = DateTime.now();
// Trouver les événements d'aujourd'hui
final todayEvents = events.where((e) {
final start = e.startDateTime;
return start.year == now.year &&
start.month == now.month &&
start.day == now.day;
}).toList()..sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
EventModel? selected;
DateTime? selectedDay;
if (todayEvents.isNotEmpty) {
selected = todayEvents[0];
selectedDay = DateTime(now.year, now.month, now.day);
} else {
// Chercher le prochain événement à venir
final futureEvents = events
.where((e) => e.startDateTime.isAfter(now))
.toList()..sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
if (futureEvents.isNotEmpty) {
selected = futureEvents[0];
final start = selected.startDateTime;
selectedDay = DateTime(start.year, start.month, start.day);
} else {
// Aucun événement à venir, prendre le plus récent
final sortedEvents = events.toList()
..sort((a, b) => b.startDateTime.compareTo(a.startDateTime));
selected = sortedEvents.first;
final start = selected.startDateTime;
selectedDay = DateTime(start.year, start.month, start.day);
}
}
if (mounted) {
setState(() {
_selectedDay = selectedDay;
_focusedDay = selectedDay!;
_selectedEventIndex = 0;
_selectedEvent = selected;
});
}
}
Future<void> _loadEvents() async {
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) {
await eventProvider.loadUserEvents(userId,
canViewAllEvents: canViewAllEvents);
}
}
/// Filtre les événements selon l'utilisateur sélectionné (si filtre actif)
/// TEMPORAIREMENT DÉSACTIVÉ - À réactiver quand permission ajoutée dans Firestore
List<EventModel> _getFilteredEvents(List<EventModel> allEvents) {
if (_selectedUserId == null) {
return allEvents; // Pas de filtre, retourner tous les événements
}
// Filtrer les événements où l'utilisateur sélectionné fait partie de la workforce
return allEvents.where((event) {
return event.workforce.any((worker) {
if (worker is String) {
return worker == _selectedUserId;
}
// Si c'est une DocumentReference, on ne peut pas facilement comparer
// On suppose que les données sont chargées correctement en String
return false;
});
}).toList();
}
void _changeWeek(int delta) {
setState(() {
_focusedDay = _focusedDay.add(Duration(days: 7 * delta));
});
}
@override
Widget build(BuildContext context) {
final eventProvider = Provider.of<EventProvider>(context);
final localUserProvider = Provider.of<LocalUserProvider>(context);
final canCreateEvents = localUserProvider.hasPermission('create_events');
final canViewAllUserEvents = localUserProvider.hasPermission('view_all_user_events');
final isMobile = MediaQuery.of(context).size.width < 600;
// 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(
child: CircularProgressIndicator(),
),
);
}
return Scaffold(
appBar: CustomAppBar(
title: "Calendrier",
),
drawer: const MainDrawer(currentPage: '/calendar'),
body: Column(
children: [
// Filtre utilisateur dans le corps de la page
if (canViewAllUserEvents && !isMobile)
Container(
padding: const EdgeInsets.all(16),
color: Colors.grey[100],
child: Row(
children: [
const Icon(Icons.filter_list, color: AppColors.rouge),
const SizedBox(width: 12),
const Text(
'Filtrer par utilisateur :',
style: TextStyle(fontWeight: FontWeight.w500, fontSize: 14),
),
const SizedBox(width: 16),
Expanded(
child: UserFilterDropdown(
selectedUserId: _selectedUserId,
onUserSelected: (userId) {
setState(() {
_selectedUserId = userId;
});
},
),
),
],
),
),
// Corps du calendrier
Expanded(
child: isMobile ? _buildMobileLayout(filteredEvents) : _buildDesktopLayout(filteredEvents),
),
],
),
floatingActionButton: canCreateEvents
? FloatingActionButton(
backgroundColor: Colors.white,
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => EventAddEditPage(
selectedDate: _selectedDay ?? DateTime.now(),
),
),
);
},
tooltip: 'Ajouter un événement',
child: const Icon(Icons.add, color: Colors.red),
)
: null,
);
}
Widget _buildDesktopLayout(List<EventModel> filteredEvents) {
return Row(
children: [
// Calendrier (65% de la largeur)
Expanded(
flex: 65,
child: _buildCalendar(filteredEvents),
),
// Détails de l'événement (35% de la largeur)
Expanded(
flex: 35,
child: _selectedEvent != null
? EventDetails(
event: _selectedEvent!,
selectedDate: _selectedDay,
events: filteredEvents,
onSelectEvent: (event, date) {
setState(() {
_selectedEvent = event;
_selectedDay = date;
});
},
)
: Center(
child: _selectedDay != null
? Text('Aucun événement ne démarre à cette date')
: const Text(
'Sélectionnez un événement pour voir les détails'),
),
),
],
);
}
Widget _buildMobileLayout(List<EventModel> filteredEvents) {
final eventsForSelectedDay = _selectedDay == null
? []
: filteredEvents
.where((e) =>
e.startDateTime.year == _selectedDay!.year &&
e.startDateTime.month == _selectedDay!.month &&
e.startDateTime.day == _selectedDay!.day)
.toList()
..sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
final hasEvents = eventsForSelectedDay.isNotEmpty;
final currentEvent =
hasEvents && _selectedEventIndex < eventsForSelectedDay.length
? eventsForSelectedDay[_selectedEventIndex]
: null;
// GESTURE DETECTOR pour swipe vertical (plier/déplier) et horizontal (mois)
return GestureDetector(
onVerticalDragEnd: (details) {
if (details.primaryVelocity != null) {
if (details.primaryVelocity! < -200) {
// Swipe vers le haut : plier
setState(() {
_calendarCollapsed = true;
});
} else if (details.primaryVelocity! > 200) {
// Swipe vers le bas : déplier
setState(() {
_calendarCollapsed = false;
});
}
}
},
onHorizontalDragEnd: (details) {
if (details.primaryVelocity != null) {
if (details.primaryVelocity! < -200) {
// Swipe gauche : mois suivant
setState(() {
_focusedDay =
DateTime(_focusedDay.year, _focusedDay.month + 1, 1);
});
} else if (details.primaryVelocity! > 200) {
// Swipe droite : mois précédent
setState(() {
_focusedDay =
DateTime(_focusedDay.year, _focusedDay.month - 1, 1);
});
}
}
},
child: Stack(
children: [
// Calendrier + détails en dessous
AnimatedPositioned(
duration: const Duration(milliseconds: 400),
curve: Curves.easeInOut,
top: _calendarCollapsed ? -600 : 0, // cache le calendrier en haut
left: 0,
right: 0,
height: _calendarCollapsed ? 0 : null,
child: SizedBox(
height: MediaQuery.of(context).size.height,
child: Column(
children: [
_buildMonthHeader(context),
if (!_calendarCollapsed)
// Ajout d'un GestureDetector pour swipe horizontal sur le calendrier
GestureDetector(
onHorizontalDragEnd: (details) {
if (details.primaryVelocity != null) {
if (details.primaryVelocity! < -200) {
// Swipe gauche : mois suivant
setState(() {
_focusedDay = DateTime(
_focusedDay.year, _focusedDay.month + 1, 1);
});
} else if (details.primaryVelocity! > 200) {
// Swipe droite : mois précédent
setState(() {
_focusedDay = DateTime(
_focusedDay.year, _focusedDay.month - 1, 1);
});
}
}
},
child: MobileCalendarView(
focusedDay: _focusedDay,
selectedDay: _selectedDay,
events: filteredEvents,
onDaySelected: (day) {
final eventsForDay = filteredEvents
.where((e) =>
e.startDateTime.year == day.year &&
e.startDateTime.month == day.month &&
e.startDateTime.day == day.day)
.toList()
..sort((a, b) =>
a.startDateTime.compareTo(b.startDateTime));
setState(() {
_selectedDay = day;
_calendarCollapsed = false;
_selectedEventIndex = 0;
_selectedEvent = eventsForDay.isNotEmpty
? eventsForDay[0]
: null;
});
},
),
),
Expanded(
child: hasEvents
// Ajout d'un GestureDetector pour swipe horizontal sur le détail événement
? GestureDetector(
onHorizontalDragEnd: (details) {
if (details.primaryVelocity != null) {
if (details.primaryVelocity! < -200) {
// Swipe gauche : événement suivant
if (_selectedEventIndex <
eventsForSelectedDay.length - 1) {
setState(() {
_selectedEventIndex++;
_selectedEvent = eventsForSelectedDay[
_selectedEventIndex];
});
}
} else if (details.primaryVelocity! > 200) {
// Swipe droite : événement précédent
if (_selectedEventIndex > 0) {
setState(() {
_selectedEventIndex--;
_selectedEvent = eventsForSelectedDay[
_selectedEventIndex];
});
}
}
}
},
child: EventDetails(
event: eventsForSelectedDay[_selectedEventIndex],
selectedDate: _selectedDay,
events: eventsForSelectedDay.cast<EventModel>(),
onSelectEvent: (event, date) {
final idx = eventsForSelectedDay
.indexWhere((e) => e.id == event.id);
setState(() {
_selectedEventIndex = idx >= 0 ? idx : 0;
_selectedEvent = event;
});
},
),
)
: Center(
child: Text(
'Aucun événement ne démarre à cette date')),
),
],
),
),
),
// Vue détail (prend tout l'espace quand calendrier caché)
if (_calendarCollapsed && _selectedDay != null)
AnimatedPositioned(
duration: const Duration(milliseconds: 400),
curve: Curves.easeInOut,
top: _calendarCollapsed ? 0 : 600,
left: 0,
right: 0,
bottom: 0,
child: SizedBox(
height: MediaQuery.of(context).size.height,
child: Column(
children: [
_buildMonthHeader(context),
Expanded(
child: Stack(
children: [
if (currentEvent != null)
// Ajout d'un GestureDetector pour swipe horizontal sur le détail événement
GestureDetector(
onHorizontalDragEnd: (details) {
if (details.primaryVelocity != null) {
if (details.primaryVelocity! < -200) {
// Swipe gauche : événement suivant
if (_selectedEventIndex <
eventsForSelectedDay.length - 1) {
setState(() {
_selectedEventIndex++;
_selectedEvent = eventsForSelectedDay[
_selectedEventIndex];
});
}
} else if (details.primaryVelocity! > 200) {
// Swipe droite : événement précédent
if (_selectedEventIndex > 0) {
setState(() {
_selectedEventIndex--;
_selectedEvent = eventsForSelectedDay[
_selectedEventIndex];
});
}
}
}
},
child: EventDetails(
event: currentEvent,
selectedDate: _selectedDay,
events: eventsForSelectedDay.cast<EventModel>(),
onSelectEvent: (event, date) {
final idx = eventsForSelectedDay
.indexWhere((e) => e.id == event.id);
setState(() {
_selectedEventIndex = idx >= 0 ? idx : 0;
_selectedEvent = event;
});
},
),
),
if (!hasEvents)
Center(
child: Text(
'Aucun événement ne démarre à cette date'),
),
],
),
),
],
),
),
),
],
),
);
}
Widget _buildMonthHeader(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(top: 8, bottom: 8),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.chevron_left,
color: AppColors.rouge, size: 28),
onPressed: () {
setState(() {
_focusedDay =
DateTime(_focusedDay.year, _focusedDay.month - 1, 1);
});
},
),
Expanded(
child: GestureDetector(
onTap: () {
setState(() {
_calendarCollapsed = !_calendarCollapsed;
});
},
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Text(
_getMonthName(_focusedDay.month),
style: const TextStyle(
color: AppColors.rouge,
fontWeight: FontWeight.bold,
fontSize: 20,
),
),
const SizedBox(width: 6),
Icon(
_calendarCollapsed
? Icons.keyboard_arrow_down
: Icons.keyboard_arrow_up,
color: AppColors.rouge,
size: 26,
),
],
),
),
),
IconButton(
icon: const Icon(Icons.chevron_right,
color: AppColors.rouge, size: 28),
onPressed: () {
setState(() {
_focusedDay =
DateTime(_focusedDay.year, _focusedDay.month + 1, 1);
});
},
),
],
),
);
}
String _getMonthName(int month) {
switch (month) {
case 1:
return 'Janvier';
case 2:
return 'Février';
case 3:
return 'Mars';
case 4:
return 'Avril';
case 5:
return 'Mai';
case 6:
return 'Juin';
case 7:
return 'Juillet';
case 8:
return 'Août';
case 9:
return 'Septembre';
case 10:
return 'Octobre';
case 11:
return 'Novembre';
case 12:
return 'Décembre';
default:
return '';
}
}
Widget _buildCalendar(List<EventModel> filteredEvents) {
if (_calendarFormat == CalendarFormat.week) {
return WeekView(
focusedDay: _focusedDay,
events: filteredEvents,
onWeekChange: _changeWeek,
onEventSelected: (event) {
setState(() {
_selectedEvent = event;
_selectedDay = event.startDateTime;
});
},
onSwitchToMonth: () {
setState(() {
_calendarFormat = CalendarFormat.month;
});
},
onDaySelected: (selectedDay) {
final eventsForDay = filteredEvents
.where((e) =>
e.startDateTime.year == selectedDay.year &&
e.startDateTime.month == selectedDay.month &&
e.startDateTime.day == selectedDay.day)
.toList();
eventsForDay
.sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
setState(() {
_selectedDay = selectedDay;
if (eventsForDay.isNotEmpty) {
_selectedEvent = eventsForDay.first;
} else {
_selectedEvent = null;
}
});
if (eventsForDay.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("Aucun événement ne démarre à cette date."),
duration: Duration(seconds: 2),
),
);
}
},
selectedEvent: _selectedEvent,
);
} else {
return MonthView(
focusedDay: _focusedDay,
selectedDay: _selectedDay,
calendarFormat: _calendarFormat,
events: filteredEvents,
onDaySelected: (selectedDay, focusedDay) {
final eventsForDay = filteredEvents
.where((event) =>
event.startDateTime.year == selectedDay.year &&
event.startDateTime.month == selectedDay.month &&
event.startDateTime.day == selectedDay.day)
.toList()
..sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
setState(() {
_selectedDay = selectedDay;
_focusedDay = focusedDay;
if (eventsForDay.isNotEmpty) {
_selectedEvent = eventsForDay.first;
} else {
_selectedEvent = null;
}
});
},
onFormatChanged: (format) {
setState(() {
_calendarFormat = format;
});
},
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(() {
_selectedEvent = event;
});
},
);
}
}
}