import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:syncfusion_flutter_calendar/calendar.dart'; import '../../../extensions/date_time.dart'; import '../../../routing/app_routes.dart'; import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart'; import '../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../state/app/modules/timetable/bloc/timetable_bloc.dart'; import '../../../state/app/modules/timetable/bloc/timetable_state.dart'; import '../../../storage/timetable_settings.dart'; import 'custom_events/custom_event_edit_dialog.dart'; import 'data/arbitrary_appointment.dart'; import 'data/lesson_period_schedule.dart'; import 'data/timetable_appointment_factory.dart'; import 'details/appointment_details_dispatcher.dart'; import 'widgets/custom_workweek_calendar.dart'; import 'widgets/special_regions_builder.dart'; enum _CalendarAction { addEvent, viewEvents } class Timetable extends StatefulWidget { const Timetable({super.key}); @override State createState() => _TimetableState(); } class _TimetableState extends State { final GlobalKey _calendarKey = GlobalKey(); List? _cachedAppointments; int? _lastDataVersion; TimetableSettings? _lastTimetableSettings; DateTime _initialDisplayDate() => DateTime.now().addDays(2); void _jumpToToday() { _calendarKey.currentState?.jumpToDate(_initialDisplayDate()); } void _onAction(_CalendarAction action) { switch (action) { case _CalendarAction.addEvent: showDialog( context: context, builder: (_) => const CustomEventEditDialog(), barrierDismissible: false, ); case _CalendarAction.viewEvents: AppRoutes.openCustomEvents(context); } } List _appointments(TimetableState state) { final timetableSettings = context .watch() .val() .timetableSettings; if (_cachedAppointments != null && _lastDataVersion == state.dataVersion && identical(_lastTimetableSettings, timetableSettings)) { return _cachedAppointments!; } _lastDataVersion = state.dataVersion; _lastTimetableSettings = timetableSettings; return _cachedAppointments = TimetableAppointmentFactory( lessons: state.getAllKnownLessons().toList(), customEvents: state.customEvents?.events ?? const [], 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; } bool _isOnInitialWeek(TimetableState state) => state.startDate == _mondayOf(_initialDisplayDate()); @override Widget build(BuildContext context) { final bloc = context.read(); final loadableState = context.watch().state; final innerState = loadableState.data; final atToday = innerState != null && _isOnInitialWeek(innerState); return Scaffold( appBar: AppBar( title: const Text('Stunden & Vertretungsplan'), actions: [ IconButton( icon: const Icon(Icons.home_outlined), onPressed: atToday ? null : _jumpToToday, ), PopupMenuButton<_CalendarAction>( icon: const Icon(Icons.edit_calendar_outlined), onSelected: _onAction, itemBuilder: (_) => const [ PopupMenuItem( value: _CalendarAction.addEvent, child: ListTile( title: Text('Kalendereintrag hinzufügen'), leading: Icon(Icons.add), ), ), PopupMenuItem( value: _CalendarAction.viewEvents, child: ListTile( title: Text('Kalendereinträge anzeigen'), leading: Icon(Icons.perm_contact_calendar_outlined), ), ), ], ), ], ), body: LoadableStateConsumer( // Without this predicate the consumer treats the freshly-initialised // empty TimetableState as "has content" and only shows the error bar // on top — but `_calendar` collapses to `SizedBox.shrink()` while the // reference data is missing, leaving the user with a blank screen. // Telling the consumer that "ready" means having reference data // flips it into the proper error-screen path instead. isReady: (state) => state.hasReferenceData, child: (state, _) => _calendar(state, bloc), ), ); } Widget _calendar(TimetableState state, TimetableBloc bloc) { if (!state.hasReferenceData) 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(); // Scroll bounds follow the Webuntis school-year API: the calendar lets // the user navigate every week the server has data for. A two-week // fallback is used only while the school-year payload hasn't loaded yet // (first launch / offline), so the calendar still mounts. final (minDate, maxDate) = _scrollBounds(state); return CustomWorkWeekCalendar( key: _calendarKey, schedule: schedule, appointments: appointments, timeRegions: regions, initialDate: _initialDisplayDate(), minDate: minDate, maxDate: maxDate, onAppointmentTap: (apt) => AppointmentDetailsDispatcher.show(context, bloc, apt), onWeekChanged: (start, end) => bloc.changeWeek(start, end), isCrossedOut: _isCrossedOut, onCreateEvent: _onCreateEventAt, ); } void _onCreateEventAt(DateTime start, DateTime end) { showDialog( context: context, builder: (_) => CustomEventEditDialog(initialStart: start, initialEnd: end), barrierDismissible: false, ); } /// 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 — /// containment for any future date-math bug that might otherwise teleport /// the user months away from today. 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 /// `-7004 no allowed date` errors during scroll — so the user can't /// slide off into territory Webuntis would refuse anyway — and finally /// clamps to a fixed window around today. /// /// minDate is snapped *forward* to the next Monday because the calendar's /// internal `_mondayOf()` would otherwise pull a mid-week minDate back /// into the just-rejected week. maxDate is passed through unsnapped — /// `_mondayOf()` correctly walks back to the Monday of its own week, /// which is the last fully-allowed week. (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); } }