Files
Client/lib/view/pages/timetable/widgets/timetable_calendar_view.dart
T

207 lines
8.0 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<CustomTimetableEvent> customEvents;
const TimetableCalendarView({
super.key,
required this.state,
required this.onWeekChanged,
required this.onAppointmentTap,
this.onCreateEvent,
this.customEvents = const [],
});
@override
State<TimetableCalendarView> createState() => TimetableCalendarViewState();
}
class TimetableCalendarViewState extends State<TimetableCalendarView> {
final GlobalKey<CustomWorkWeekCalendarState> _calendarKey =
GlobalKey<CustomWorkWeekCalendarState>();
List<Appointment>? _cachedAppointments;
int? _lastDataVersion;
TimetableSettings? _lastTimetableSettings;
List<CustomTimetableEvent>? _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<Appointment> _appointments(TimetableState state) {
final timetableSettings = context
.watch<SettingsCubit>()
.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<CapabilitiesCubit>();
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 MonSun 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;
}