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/capabilities/bloc/capabilities_cubit.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()); } 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 [], holidays: state.schoolHolidays?.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 capabilities = context.watch(); final (minDate, maxDate) = _scrollBounds( state, pastDays: capabilities.timetablePastDays, futureDays: capabilities.timetableFutureDays, ); 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, ); } /// Returns the (minDate, maxDate) the user is allowed to scroll between. /// Starts from the (server-narrowed) 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 the window Connect grants via /// [CapabilitiesCubit.timetablePastDays]/[CapabilitiesCubit.timetableFutureDays]. /// /// The capability clamp mirrors the server: `null` means unlimited (no client /// clamp at all), and a given day count is widened to at least cover the /// current Mon–Sun week — so the two windows always coincide and the clamp /// can never invert. (DateTime, DateTime) _scrollBounds( TimetableState state, { required int? pastDays, required int? futureDays, }) { 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 today = _startOfDay(DateTime.now()); final todayMonday = _mondayOf(today); final currentWeekEnd = todayMonday.addDays(DateTime.daysPerWeek - 1); final capMin = pastDays == null ? null : _earlier(today.subtractDays(pastDays), todayMonday); final capMax = futureDays == null ? null : _later(today.addDays(futureDays), currentWeekEnd); final cappedMin = capMin != null && effectiveMin.isBefore(capMin) ? capMin : effectiveMin; final cappedMax = capMax != null && effectiveMax.isAfter(capMax) ? capMax : effectiveMax; // When the resulting range does not cover the current week — the summer gap // between two school years, or a stale persisted bound — fall back to the // current week, widened to whatever the capabilities still allow. Otherwise // the PageView clamps the initial page to the last week before the holidays // (hiding the "Schulfrei" region) and forward scrolling collapses to the // current week only. final outsideRange = cappedMax.isBefore(todayMonday) || cappedMin.isAfter(currentWeekEnd); final finalMin = outsideRange ? (capMin ?? todayMonday) : cappedMin; final finalMax = outsideRange ? (capMax ?? currentWeekEnd) : cappedMax; final daysToMonday = (DateTime.monday - finalMin.weekday) % DateTime.daysPerWeek; final mondayMin = finalMin.addDays(daysToMonday); return (mondayMin, finalMax); } static DateTime _mondayOf(DateTime d) => _startOfDay(d.subtractDays(d.weekday - 1)); static DateTime _startOfDay(DateTime d) => DateTime(d.year, d.month, d.day); static DateTime _earlier(DateTime a, DateTime b) => a.isBefore(b) ? a : b; static DateTime _later(DateTime a, DateTime b) => a.isAfter(b) ? a : b; }