Files
EM2_ERP/em2rp/lib/views/calendar_page.dart
T
ElPoyo af5ecaeee1 feat: optimisation du démarrage de l'application et de la gestion de l'authentification
- **Refonte du démarrage** : Mise en place d'un `AppInitializer` pour gérer l'initialisation asynchrone de Firebase et du cache en arrière-plan, réduisant le travail synchrone au lancement.
- **Sécurisation de l'authentification** :
    - Création d'un `AppStartGate` pour gérer proprement la restauration de la session Firebase Auth et les erreurs potentielles sur le Web.
    - Amélioration du `LocalUserProvider` avec un "bootstrap léger" permettant de rendre l'UID disponible immédiatement avant le chargement complet du profil.
    - Ajout de protections contre les erreurs d'accès à `FirebaseAuth.instance` (notamment pour les problèmes d'interop JS sur le Web).
- **Optimisation de l'UI** :
    - Remplacement du `AutoLoginWrapper` par une gestion plus robuste de la navigation post-authentification.
    - Amélioration de l'`AuthGuard` pour permettre l'affichage de certains écrans (comme le calendrier) pendant le chargement des données utilisateur (`allowWhileLoading`).
    - Ajout d'un écran de splash screen uniformisé (`StartupSplashScreen`).
- **Services & Cache** :
    - Introduction de `CacheService` utilisant `shared_preferences` pour le stockage local léger.
    - Refactoring des services (`AlertService`, `EmailService`, `FirebaseStorageManager`) pour accéder aux instances Firebase de manière plus flexible via des getters.
    - Mise à jour des dépendances dans `pubspec.yaml` pour inclure `shared_preferences`.
- **Calendrier** : Ajout d'une logique de chargement initial différé des événements (`_scheduleInitialEventsLoad`) pour éviter les appels redondants au démarrage.
- **Maintenance** : Mise à jour de la version de l'application à `1.1.23` et nettoyage des fichiers de cache de déploiement.
2026-05-05 12:25:45 +02:00

1400 lines
47 KiB
Dart

import 'dart:async';
import 'dart:math' as math;
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/intl.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> {
static const double _minDetailsPaneFraction = 0.25;
static const double _maxDetailsPaneFraction = 0.5;
static const double _desktopResizeHandleWidth = 12;
static const double _minCalendarPaneWidth = 480;
static const double _minDetailsPaneWidth = 320;
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)
final TextEditingController _searchController = TextEditingController();
Timer? _searchDebounce;
List<EventModel> _searchResults = [];
String _searchQuery = '';
String? _searchError;
bool _isSearching = false;
int _searchRequestId = 0;
bool _isMobileSearchVisible = false;
bool _isRefreshing = false;
double _detailsPaneFraction = 0.35;
String? _lastLoadedUserId;
bool _initialLoadScheduled = false;
@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');
}
/// Vide le cache et recharge les événements du mois courant
Future<void> _refreshEvents() async {
if (_isRefreshing) return;
setState(() => _isRefreshing = true);
try {
final eventProvider = Provider.of<EventProvider>(context, listen: false);
eventProvider.clearAllCache();
await _loadCurrentMonthEvents();
} finally {
if (mounted) setState(() => _isRefreshing = false);
}
}
void _scheduleInitialEventsLoad(String? userId) {
if (userId == null || userId == _lastLoadedUserId || _initialLoadScheduled) {
return;
}
_initialLoadScheduled = true;
WidgetsBinding.instance.addPostFrameCallback((_) async {
try {
if (!mounted) return;
if (_lastLoadedUserId == userId) return;
await _loadCurrentMonthEvents();
_lastLoadedUserId = userId;
} finally {
_initialLoadScheduled = false;
}
});
}
/// 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);
}
}
@override
void dispose() {
_searchDebounce?.cancel();
_searchController.dispose();
super.dispose();
}
/// Filtre les événements selon l'utilisateur sélectionné (si filtre actif).
List<EventModel> _filterEventsByUser(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();
}
bool _isSameDay(DateTime left, DateTime right) {
return left.year == right.year &&
left.month == right.month &&
left.day == right.day;
}
List<EventModel> _getEventsForDay(
List<EventModel> events,
DateTime? day, {
EventModel? selectedEvent,
}) {
if (day == null) {
return [];
}
final dayEvents = events
.where((event) => _isSameDay(event.startDateTime, day))
.toList()
..sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
if (selectedEvent != null &&
_isSameDay(selectedEvent.startDateTime, day) &&
!dayEvents.any((event) => event.id == selectedEvent.id)) {
dayEvents.add(selectedEvent);
dayEvents.sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
}
return dayEvents;
}
List<EventModel> _getDetailsEvents(List<EventModel> events) {
final mergedEvents = [...events];
if (_selectedEvent != null &&
!mergedEvents.any((event) => event.id == _selectedEvent!.id)) {
mergedEvents.add(_selectedEvent!);
}
mergedEvents.sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
return mergedEvents;
}
String _formatSearchResultDate(DateTime dateTime) {
return DateFormat('EEE d MMM yyyy • HH:mm', 'fr_FR').format(dateTime);
}
Color _getStatusColor(EventStatus status) {
switch (status) {
case EventStatus.confirmed:
return Colors.green;
case EventStatus.canceled:
return Colors.red;
case EventStatus.waitingForApproval:
default:
return Colors.amber;
}
}
/// Combine uniquement le filtre utilisateur avec la vue calendrier.
List<EventModel> _getFilteredEvents(List<EventModel> allEvents) {
return _filterEventsByUser(allEvents);
}
void _cancelPendingSearch() {
_searchDebounce?.cancel();
_searchDebounce = null;
}
void _scheduleSearch(String value) {
_cancelPendingSearch();
_searchDebounce = Timer(const Duration(milliseconds: 300), () {
_runSearch(value);
});
}
void _onSearchChanged(String value) {
final isMobile = MediaQuery.of(context).size.width < 600;
if (isMobile && value.isNotEmpty && !_isMobileSearchVisible) {
setState(() {
_isMobileSearchVisible = true;
});
}
setState(() {
_searchQuery = value;
});
if (value.trim().isEmpty) {
_cancelPendingSearch();
setState(() {
_searchResults = [];
_searchError = null;
_isSearching = false;
});
return;
}
_scheduleSearch(value);
}
void _clearSearch() {
_cancelPendingSearch();
if (_searchController.text.isEmpty) {
return;
}
_searchController.clear();
setState(() {
_searchQuery = '';
_searchResults = [];
_searchError = null;
_isSearching = false;
});
}
Future<void> _runSearch(String value) async {
final query = value.trim();
if (query.isEmpty) {
return;
}
final localUserProvider = context.read<LocalUserProvider>();
final userId = localUserProvider.uid;
if (userId == null) {
return;
}
final searchId = ++_searchRequestId;
setState(() {
_isSearching = true;
_searchError = null;
_searchResults = [];
});
try {
final eventProvider = context.read<EventProvider>();
final results = await eventProvider.searchEvents(
userId: userId,
query: query,
);
if (!mounted) {
return;
}
if (_searchQuery.trim() != query) {
return;
}
if (searchId != _searchRequestId) {
return;
}
setState(() {
_searchResults = results;
_searchError = null;
_isSearching = false;
});
} catch (e) {
if (!mounted || _searchQuery.trim() != query) {
return;
}
setState(() {
_searchResults = [];
_searchError = 'Erreur lors de la recherche : $e';
_isSearching = false;
});
}
}
Widget _buildDesktopFiltersBar({required bool canViewAllUserEvents}) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
color: Colors.grey[100],
child: Row(
children: [
Expanded(
child: TextField(
controller: _searchController,
onChanged: _onSearchChanged,
decoration: InputDecoration(
hintText: 'Rechercher (titre, description, lieu)',
prefixIcon: const Icon(Icons.search, color: AppColors.rouge),
suffixIcon: _searchQuery.isNotEmpty
? IconButton(
tooltip: 'Effacer la recherche',
icon: const Icon(Icons.close),
onPressed: _clearSearch,
)
: null,
isDense: true,
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide.none,
),
),
),
),
if (canViewAllUserEvents) ...[
const SizedBox(width: 12),
_buildCompactUserFilter(),
],
],
),
);
}
Widget _buildCompactUserFilter() {
return ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 300),
child: Row(
children: [
Expanded(
child: UserFilterDropdown(
selectedUserId: _selectedUserId,
onUserSelected: (userId) {
setState(() {
_selectedUserId = userId;
});
},
),
),
],
),
);
}
Widget _buildMobileSearchBar() {
return Container(
color: Colors.grey[100],
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Column(
children: [
Row(
children: [
IconButton(
icon: Icon(
_isMobileSearchVisible ? Icons.search_off : Icons.search,
color: AppColors.rouge,
),
tooltip: _isMobileSearchVisible
? 'Masquer la recherche'
: 'Afficher la recherche',
onPressed: () {
setState(() {
_isMobileSearchVisible = !_isMobileSearchVisible;
});
},
),
Expanded(
child: Text(
_searchQuery.isEmpty
? 'Rechercher un événement'
: 'Recherche active',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
if (_searchQuery.isNotEmpty)
IconButton(
icon: const Icon(Icons.close),
tooltip: 'Effacer la recherche',
onPressed: _clearSearch,
),
],
),
AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: _isMobileSearchVisible
? Padding(
key: const ValueKey('mobile-search-visible'),
padding: const EdgeInsets.only(top: 4, left: 8, right: 8),
child: TextField(
controller: _searchController,
onChanged: _onSearchChanged,
decoration: InputDecoration(
hintText: 'Titre, description ou lieu',
prefixIcon:
const Icon(Icons.search, color: AppColors.rouge),
isDense: true,
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide.none,
),
),
),
)
: const SizedBox.shrink(
key: ValueKey('mobile-search-hidden'),
),
),
],
),
);
}
Widget _buildSearchResultsPanel({required bool isMobile}) {
final hasQuery = _searchQuery.trim().isNotEmpty;
if (!hasQuery && !_isSearching && _searchError == null) {
return const SizedBox.shrink();
}
final panelPadding = EdgeInsets.symmetric(
horizontal: isMobile ? 8 : 16,
vertical: 8,
);
return Container(
width: double.infinity,
padding: panelPadding,
color: Colors.grey[50],
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
const Icon(Icons.manage_search, color: AppColors.rouge, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
hasQuery
? 'Résultats pour "$_searchQuery"'
: 'Recherche d’événements',
style: const TextStyle(
fontWeight: FontWeight.w600,
),
),
),
if (_isSearching)
const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
),
],
),
if (_searchError != null) ...[
const SizedBox(height: 8),
Text(
_searchError!,
style: const TextStyle(color: Colors.red),
),
] else if (!hasQuery) ...[
const SizedBox(height: 8),
Text(
'Saisissez un titre, une description ou un lieu pour lancer la recherche.',
style: TextStyle(color: Colors.grey.shade700),
),
] else if (!_isSearching) ...[
const SizedBox(height: 8),
if (_searchResults.isEmpty)
Text(
'Aucun résultat trouvé.',
style: TextStyle(color: Colors.grey.shade700),
)
else
ConstrainedBox(
constraints: BoxConstraints(
maxHeight: isMobile ? 240 : 280,
),
child: ListView.separated(
shrinkWrap: true,
itemCount: _searchResults.length,
physics: const ClampingScrollPhysics(),
separatorBuilder: (context, index) =>
const SizedBox(height: 8),
itemBuilder: (context, index) {
final event = _searchResults[index];
final isSelected = _selectedEvent?.id == event.id;
return Material(
color: isSelected
? AppColors.rouge.withOpacity(0.08)
: Colors.white,
borderRadius: BorderRadius.circular(12),
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () => _onSearchResultSelected(event),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: _getStatusColor(event.status),
shape: BoxShape.circle,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
event.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
_formatSearchResultDate(
event.startDateTime),
style: TextStyle(
color: Colors.grey.shade700,
fontSize: 12,
),
),
if (event.address.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
event.address,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.grey.shade600,
fontSize: 12,
),
),
],
],
),
),
const SizedBox(width: 8),
const Icon(Icons.chevron_right,
color: Colors.grey),
],
),
),
),
);
},
),
),
],
],
),
);
}
Future<void> _onSearchResultSelected(EventModel event) async {
final localUserProvider = context.read<LocalUserProvider>();
final eventProvider = context.read<EventProvider>();
final userId = localUserProvider.uid;
if (userId == null) {
return;
}
final canViewAllEvents = localUserProvider.hasPermission('view_all_events');
final selectedDay = DateTime(
event.startDateTime.year,
event.startDateTime.month,
event.startDateTime.day,
);
final shouldLoadMonth = _focusedDay.year != event.startDateTime.year ||
_focusedDay.month != event.startDateTime.month ||
eventProvider.events.isEmpty;
if (shouldLoadMonth) {
await eventProvider.loadMonthEvents(
userId,
event.startDateTime.year,
event.startDateTime.month,
canViewAllEvents: canViewAllEvents,
);
eventProvider.preloadAdjacentMonths(
userId,
event.startDateTime.year,
event.startDateTime.month,
canViewAllEvents: canViewAllEvents,
);
}
if (!mounted) {
return;
}
final eventsForSelectedDay = _getEventsForDay(
eventProvider.events,
selectedDay,
selectedEvent: event,
);
final isMobile = MediaQuery.of(context).size.width < 600;
setState(() {
_focusedDay = selectedDay;
_selectedDay = selectedDay;
_selectedEvent = event;
_selectedEventIndex =
eventsForSelectedDay.indexWhere((e) => e.id == event.id);
if (_selectedEventIndex < 0) {
_selectedEventIndex = 0;
}
_calendarCollapsed = false;
if (isMobile) {
_isMobileSearchVisible = true;
}
});
}
void _changeWeek(int delta) {
setState(() {
_focusedDay = _focusedDay.add(Duration(days: 7 * delta));
});
}
double _clampDetailsPaneFraction(double fraction, double totalWidth) {
if (totalWidth <= 0) {
return fraction.clamp(_minDetailsPaneFraction, _maxDetailsPaneFraction);
}
final minFractionFromPixels = _minDetailsPaneWidth / totalWidth;
final maxFractionFromPixels =
(totalWidth - _desktopResizeHandleWidth - _minCalendarPaneWidth) /
totalWidth;
final minFraction =
math.max(_minDetailsPaneFraction, minFractionFromPixels);
final maxFraction =
math.min(_maxDetailsPaneFraction, maxFractionFromPixels);
if (maxFraction < minFraction) {
return fraction.clamp(_minDetailsPaneFraction, _maxDetailsPaneFraction);
}
return fraction.clamp(minFraction, maxFraction);
}
Widget _buildDesktopDetailsPane(List<EventModel> filteredEvents) {
if (_selectedEvent != null) {
final detailsEvents = _getDetailsEvents(filteredEvents);
return EventDetails(
event: _selectedEvent!,
selectedDate: _selectedDay,
events: detailsEvents,
onSelectEvent: (event, date) {
setState(() {
_selectedEvent = event;
_selectedDay = date;
});
},
);
}
return Center(
child: _selectedDay != null
? const Text('Aucun événement ne démarre à cette date')
: const Text('Sélectionnez un événement pour voir les détails'),
);
}
Widget _buildDesktopResizeHandle(double totalWidth) {
return MouseRegion(
cursor: SystemMouseCursors.resizeLeftRight,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onHorizontalDragUpdate: (details) {
setState(() {
_detailsPaneFraction = _clampDetailsPaneFraction(
_detailsPaneFraction - (details.delta.dx / totalWidth),
totalWidth,
);
});
},
child: SizedBox(
width: _desktopResizeHandleWidth,
child: Center(
child: Container(
width: 4,
margin: const EdgeInsets.symmetric(vertical: 16),
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(999),
),
),
),
),
),
);
}
@override
Widget build(BuildContext context) {
final eventProvider = Provider.of<EventProvider>(context);
final localUserProvider = Provider.of<LocalUserProvider>(context);
_scheduleInitialEventsLoad(localUserProvider.uid);
final canCreateEvents = localUserProvider.hasPermission('create_events');
final canViewAllUserEvents =
localUserProvider.hasPermission('view_all_user_events');
final isMobile = MediaQuery.of(context).size.width < 600;
final showSearchResults =
_searchQuery.trim().isNotEmpty || _isSearching || _searchError != null;
// 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",
actions: [
if (_isRefreshing)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 12),
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
),
)
else
IconButton(
icon: const Icon(Icons.refresh, color: Colors.white),
tooltip: 'Mettre à jour les événements',
onPressed: _refreshEvents,
),
],
),
drawer: const MainDrawer(currentPage: '/calendar'),
body: Column(
children: [
if (isMobile)
_buildMobileSearchBar()
else
_buildDesktopFiltersBar(canViewAllUserEvents: canViewAllUserEvents),
if (showSearchResults) _buildSearchResultsPanel(isMobile: isMobile),
// 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 LayoutBuilder(
builder: (context, constraints) {
final totalWidth = constraints.maxWidth;
final detailsPaneFraction =
_clampDetailsPaneFraction(_detailsPaneFraction, totalWidth);
final detailsWidth = totalWidth * detailsPaneFraction;
final calendarWidth =
totalWidth - _desktopResizeHandleWidth - detailsWidth;
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
width: calendarWidth,
child: _buildCalendar(filteredEvents),
),
_buildDesktopResizeHandle(totalWidth),
SizedBox(
width: detailsWidth,
child: _buildDesktopDetailsPane(filteredEvents),
),
],
);
},
);
}
Widget _buildMobileLayout(List<EventModel> filteredEvents) {
final eventsForSelectedDay = _getEventsForDay(
filteredEvents,
_selectedDay,
selectedEvent: _selectedEvent,
);
final hasEvents = eventsForSelectedDay.isNotEmpty;
final selectedEventIndex = _selectedEvent == null
? -1
: eventsForSelectedDay
.indexWhere((event) => event.id == _selectedEvent!.id);
final currentEvent = hasEvents && selectedEventIndex >= 0
? eventsForSelectedDay[selectedEventIndex]
: 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
final newMonth =
DateTime(_focusedDay.year, _focusedDay.month + 1, 1);
setState(() {
_focusedDay = newMonth;
});
print(
'[CalendarPage] Month changed to ${newMonth.year}-${newMonth.month}');
_loadCurrentMonthEvents();
} else if (details.primaryVelocity! > 200) {
// Swipe droite : mois précédent
final newMonth =
DateTime(_focusedDay.year, _focusedDay.month - 1, 1);
setState(() {
_focusedDay = newMonth;
});
print(
'[CalendarPage] Month changed to ${newMonth.year}-${newMonth.month}');
_loadCurrentMonthEvents();
}
}
},
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
final newMonth = DateTime(
_focusedDay.year, _focusedDay.month + 1, 1);
setState(() {
_focusedDay = newMonth;
});
print(
'[CalendarPage] Month changed to ${newMonth.year}-${newMonth.month}');
_loadCurrentMonthEvents();
} else if (details.primaryVelocity! > 200) {
// Swipe droite : mois précédent
final newMonth = DateTime(
_focusedDay.year, _focusedDay.month - 1, 1);
setState(() {
_focusedDay = newMonth;
});
print(
'[CalendarPage] Month changed to ${newMonth.year}-${newMonth.month}');
_loadCurrentMonthEvents();
}
}
},
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,
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 cache)
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,
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: () {
final newMonth =
DateTime(_focusedDay.year, _focusedDay.month - 1, 1);
setState(() {
_focusedDay = newMonth;
});
print(
'[CalendarPage] Month changed to ${newMonth.year}-${newMonth.month}');
_loadCurrentMonthEvents();
},
),
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: () {
final newMonth =
DateTime(_focusedDay.year, _focusedDay.month + 1, 1);
setState(() {
_focusedDay = newMonth;
});
print(
'[CalendarPage] Month changed to ${newMonth.year}-${newMonth.month}');
_loadCurrentMonthEvents();
},
),
],
),
);
}
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;
});
},
);
}
}
}