207 lines
8.0 KiB
Dart
207 lines
8.0 KiB
Dart
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 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;
|
||
}
|