eac103491f
- **Recherche d'événements** : Ajout d'une fonctionnalité de recherche (titre, description, lieu) dans le calendrier et d'une nouvelle fonction Cloud `searchEvents` avec gestion des permissions.
- **Suppression d'équipement avec forçage** :
- Mise à jour de la fonction Cloud `deleteEquipment` pour détecter les assignations à des événements futurs.
- Ajout d'une option `forceDelete` pour passer outre les conflits d'assignation.
- Création de `EquipmentDeleteUtils` pour gérer uniformément les dialogues de confirmation et les erreurs de conflit (HTTP 409).
- Intégration de la logique de suppression sécurisée dans `EquipmentDetailPage` et `EquipmentManagementPage`.
- **Calendrier** :
- Refonte de l'interface mobile pour intégrer la barre de recherche.
- Optimisation du chargement des événements lors de la sélection d'un résultat de recherche (lazy loading du mois concerné).
- Amélioration de la stabilité de la sélection d'événements et du filtrage par utilisateur.
- **Services & Providers** :
- Amélioration de la gestion des erreurs dans `ApiService` pour faciliter le re-throw des exceptions personnalisées.
- Ajout du support de la suppression forcée dans `DataService` et `EquipmentProvider`.
- **Refactoring** : Nettoyage du code, amélioration du formatage et ajout de logs de debug dans les services de données et d'équipements.
1379 lines
46 KiB
Dart
1379 lines
46 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;
|
|
|
|
@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);
|
|
}
|
|
}
|
|
|
|
/// 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);
|
|
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;
|
|
});
|
|
},
|
|
);
|
|
}
|
|
}
|
|
}
|