import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:syncfusion_flutter_calendar/calendar.dart'; import '../../../routing/app_routes.dart'; import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart'; import '../../../state/app/modules/commute/bloc/commute_cubit.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/commute_appointment_factory.dart'; import 'data/lesson_period_schedule.dart'; import 'data/timetable_appointment_factory.dart'; import 'data/webuntis_time.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; Map? _lastCommuteState; DateTime _initialDisplayDate() => DateTime.now().add(const Duration(days: 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; final commuteState = context.watch().state; // Kick off any missing commute fetches for the currently visible weeks. // The cubit's ttl/inflight guards make this safe to call on every build. _maybeRequestCommute(state, timetableSettings); if (_cachedAppointments != null && _lastDataVersion == state.dataVersion && identical(_lastTimetableSettings, timetableSettings) && identical(_lastCommuteState, commuteState)) { return _cachedAppointments!; } _lastDataVersion = state.dataVersion; _lastTimetableSettings = timetableSettings; _lastCommuteState = commuteState; final base = TimetableAppointmentFactory( lessons: state.getAllKnownLessons().toList(), customEvents: state.customEvents?.events ?? const [], rooms: state.rooms!, subjects: state.subjects!, settings: timetableSettings, now: DateTime.now(), ).build(); if (!timetableSettings.showCommuteInTimetable || commuteState.isEmpty) { return _cachedAppointments = base; } final commute = []; for (final entry in commuteState.values) { commute.addAll( CommuteAppointmentFactory.build( morning: entry.morning, evening: entry.evening, ), ); } return _cachedAppointments = [...base, ...commute]; } void _maybeRequestCommute( TimetableState state, TimetableSettings timetableSettings, ) { if (!timetableSettings.showCommuteInTimetable) return; if (timetableSettings.homeStation == null) return; if (timetableSettings.schoolStation == null) return; final spans = _lessonSpansByDay(state); if (spans.isEmpty) return; context.read().ensureLoaded( lessonsByDay: spans, settings: timetableSettings, ); } Map _lessonSpansByDay(TimetableState state) { final byDay = {}; for (final lesson in state.getAllKnownLessons()) { try { final start = WebuntisTime.parse(lesson.date, lesson.startTime); final end = WebuntisTime.parse(lesson.date, lesson.endTime); final day = DateTime(start.year, start.month, start.day); final existing = byDay[day]; if (existing == null) { byDay[day] = _MinMax(start, end); } else { if (start.isBefore(existing.min)) existing.min = start; if (end.isAfter(existing.max)) existing.max = end; } } catch (_) { // Skip lessons we can't parse — same fallback as elsewhere. } } return { for (final entry in byDay.entries) entry.key: LessonSpan(entry.value.min, entry.value.max), }; } bool _isCrossedOut(Appointment appointment) { final id = appointment.id; if (id is WebuntisAppointment) return id.lesson.code == 'cancelled'; if (id is CommuteAppointment) { // Strike the tile only if literally every leg is cancelled — partially // cancelled trips still get the user somewhere and should stay legible. final legs = id.trip.legs; return legs.isNotEmpty && legs.every((l) => l.cancelled || l.partCancelled); } return false; } bool _isOnInitialWeek(TimetableState state) { final target = _initialDisplayDate(); final targetMonday = target.subtract(Duration(days: target.weekday - 1)); final mondayOnly = DateTime( targetMonday.year, targetMonday.month, targetMonday.day, ); return state.startDate == mondayOnly; } @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( 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, ); } /// 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) and 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. /// /// 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?.result; final DateTime baseMin; final DateTime baseMax; if (year != null) { baseMin = WebuntisTime.parse(year.startDate, 0); baseMax = WebuntisTime.parse(year.endDate, 0); } else { final now = DateTime.now(); baseMin = now.subtract(const Duration(days: 14)); baseMax = now.add(const Duration(days: 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 daysToMonday = (DateTime.monday - effectiveMin.weekday) % DateTime.daysPerWeek; final mondayMin = effectiveMin.add(Duration(days: daysToMonday)); return (mondayMin, effectiveMax); } } class _MinMax { DateTime min; DateTime max; _MinMax(this.min, this.max); }