300 lines
10 KiB
Dart
300 lines
10 KiB
Dart
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<Timetable> createState() => _TimetableState();
|
|
}
|
|
|
|
class _TimetableState extends State<Timetable> {
|
|
final GlobalKey<CustomWorkWeekCalendarState> _calendarKey =
|
|
GlobalKey<CustomWorkWeekCalendarState>();
|
|
|
|
List<Appointment>? _cachedAppointments;
|
|
int? _lastDataVersion;
|
|
TimetableSettings? _lastTimetableSettings;
|
|
Map<String, CommuteDayEntry>? _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<Appointment> _appointments(TimetableState state) {
|
|
final timetableSettings = context
|
|
.watch<SettingsCubit>()
|
|
.val()
|
|
.timetableSettings;
|
|
final commuteState = context.watch<CommuteCubit>().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 = <Appointment>[];
|
|
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<CommuteCubit>().ensureLoaded(
|
|
lessonsByDay: spans,
|
|
settings: timetableSettings,
|
|
);
|
|
}
|
|
|
|
Map<DateTime, LessonSpan> _lessonSpansByDay(TimetableState state) {
|
|
final byDay = <DateTime, _MinMax>{};
|
|
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<TimetableBloc>();
|
|
final loadableState = context.watch<TimetableBloc>().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<TimetableBloc, TimetableState>(
|
|
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);
|
|
}
|