import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:syncfusion_flutter_calendar/calendar.dart'; import '../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart'; import '../../../../extensions/date_time.dart'; import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../../state/app/modules/timetable/bloc/timetable_state.dart'; import '../../../../storage/timetable_settings.dart'; import '../data/arbitrary_appointment.dart'; import '../data/lesson_period_schedule.dart'; import '../data/timetable_appointment_factory.dart'; import 'custom_workweek_calendar.dart'; import 'special_regions_builder.dart'; /// Renders a weekly timetable from a [TimetableState]. Shared by the user's own /// plan and the foreign-element view; the only differences are which custom /// events to overlay (none for foreign plans) and whether tapping an empty slot /// can create an event ([onCreateEvent] is null for read-only foreign plans). /// /// The week navigation and appointment-tap callbacks are supplied by the host /// page so each can route them to its own bloc. class TimetableCalendarView extends StatefulWidget { final TimetableState state; final void Function(DateTime weekStart, DateTime weekEnd) onWeekChanged; final void Function(Appointment appointment) onAppointmentTap; final void Function(DateTime start, DateTime end)? onCreateEvent; final List customEvents; const TimetableCalendarView({ super.key, required this.state, required this.onWeekChanged, required this.onAppointmentTap, this.onCreateEvent, this.customEvents = const [], }); @override State createState() => TimetableCalendarViewState(); } class TimetableCalendarViewState extends State { final GlobalKey _calendarKey = GlobalKey(); List? _cachedAppointments; int? _lastDataVersion; TimetableSettings? _lastTimetableSettings; List? _lastCustomEvents; DateTime _initialDisplayDate() => DateTime.now().addDays(2); /// Snaps the calendar back to the current week. Exposed so host pages can /// wire it to a "today" AppBar action. void jumpToToday() { _calendarKey.currentState?.jumpToDate(_initialDisplayDate()); } bool isOnInitialWeek() => widget.state.startDate == _mondayOf(_initialDisplayDate()); List _appointments(TimetableState state) { final timetableSettings = context .watch() .val() .timetableSettings; if (_cachedAppointments != null && _lastDataVersion == state.dataVersion && identical(_lastTimetableSettings, timetableSettings) && identical(_lastCustomEvents, widget.customEvents)) { return _cachedAppointments!; } _lastDataVersion = state.dataVersion; _lastTimetableSettings = timetableSettings; _lastCustomEvents = widget.customEvents; return _cachedAppointments = TimetableAppointmentFactory( lessons: state.getAllKnownLessons().toList(), customEvents: widget.customEvents, subjects: state.subjects?.result ?? const [], settings: timetableSettings, now: DateTime.now(), ).build(); } bool _isCrossedOut(Appointment appointment) { final id = appointment.id; if (id is LessonAppointment) return id.entry.status == 'CANCELLED'; return false; } @override Widget build(BuildContext context) { final state = widget.state; // Reference data is gated by the host's LoadableStateConsumer isReady // predicate, but guard the one hard dereference (schoolHolidays) so a // transient null can never crash the build. if (state.schoolHolidays == null) return const SizedBox.shrink(); final schedule = LessonPeriodSchedule.fromState(state); final appointments = _appointments(state); final regions = SpecialRegionsBuilder( holidays: state.schoolHolidays!, schedule: schedule, colorScheme: Theme.of(context).colorScheme, disabledColor: Theme.of(context).disabledColor, ).build(); final (minDate, maxDate) = _scrollBounds(state); return CustomWorkWeekCalendar( key: _calendarKey, schedule: schedule, appointments: appointments, timeRegions: regions, initialDate: _initialDisplayDate(), minDate: minDate, maxDate: maxDate, onAppointmentTap: widget.onAppointmentTap, onWeekChanged: widget.onWeekChanged, isCrossedOut: _isCrossedOut, onCreateEvent: widget.onCreateEvent, ); } /// Hard caps applied on top of whatever Webuntis would allow. Even if the /// school year (or a stale persisted bound) would let the user scroll /// further, we never expose more than this much around the current week. static const int _maxWeeksBack = 4; static const int _maxWeeksForward = 2; /// Returns the (minDate, maxDate) the user is allowed to scroll between. /// Starts from the Webuntis school year (or a tight window when that hasn't /// loaded yet), tightens by anything the bloc has learned from past denials, /// and finally clamps to a fixed window around today. (DateTime, DateTime) _scrollBounds(TimetableState state) { final year = state.schoolyear; final DateTime baseMin; final DateTime baseMax; if (year != null) { baseMin = year.startDate; baseMax = year.endDate; } else { final now = DateTime.now(); baseMin = now.subtractDays(14); baseMax = now.addDays(7); } final effectiveMin = state.accessibleStartDate != null ? (state.accessibleStartDate!.isAfter(baseMin) ? state.accessibleStartDate! : baseMin) : baseMin; final effectiveMax = state.accessibleEndDate != null ? (state.accessibleEndDate!.isBefore(baseMax) ? state.accessibleEndDate! : baseMax) : baseMax; final todayMonday = _mondayOf(DateTime.now()); final cappedMin = effectiveMin.isBefore( todayMonday.subtractDays(_maxWeeksBack * 7), ) ? todayMonday.subtractDays(_maxWeeksBack * 7) : effectiveMin; final cappedMax = effectiveMax.isAfter( todayMonday.addDays(_maxWeeksForward * 7 + 6), ) ? todayMonday.addDays(_maxWeeksForward * 7 + 6) : effectiveMax; final daysToMonday = (DateTime.monday - cappedMin.weekday) % DateTime.daysPerWeek; final mondayMin = cappedMin.addDays(daysToMonday); return (mondayMin, cappedMax); } static DateTime _mondayOf(DateTime d) { final monday = d.subtractDays(d.weekday - 1); return DateTime(monday.year, monday.month, monday.day); } }