181 lines
6.7 KiB
Dart
181 lines
6.7 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/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());
|
|
}
|
|
|
|
bool isOnInitialWeek() =>
|
|
widget.state.startDate == _mondayOf(_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 [],
|
|
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 (minDate, maxDate) = _scrollBounds(state);
|
|
|
|
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,
|
|
);
|
|
}
|
|
|
|
/// 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.
|
|
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 past denials,
|
|
/// and finally clamps to a fixed window around today.
|
|
(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);
|
|
}
|
|
}
|