migrated timetable integration from WebUntis to the MarianumConnect API, implementing a Dio-based client with bearer token authentication, background session validation, and auto-refresh logic.

This commit is contained in:
2026-05-23 17:32:42 +02:00
parent 2858f910c9
commit 93b9929f8f
106 changed files with 2739 additions and 2624 deletions
@@ -1,21 +1,21 @@
import '../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
import '../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart';
import '../../../../api/webuntis/queries/get_timetable/get_timetable_response.dart';
sealed class ArbitraryAppointment {
const ArbitraryAppointment();
T when<T>({
required T Function(GetTimetableResponseObject lesson) webuntis,
required T Function(McTimetableEntry lesson) lesson,
required T Function(CustomTimetableEvent event) custom,
}) => switch (this) {
WebuntisAppointment(:final lesson) => webuntis(lesson),
LessonAppointment(:final entry) => lesson(entry),
CustomAppointment(:final event) => custom(event),
};
}
class WebuntisAppointment extends ArbitraryAppointment {
final GetTimetableResponseObject lesson;
const WebuntisAppointment(this.lesson);
class LessonAppointment extends ArbitraryAppointment {
final McTimetableEntry entry;
const LessonAppointment(this.entry);
}
class CustomAppointment extends ArbitraryAppointment {
@@ -282,7 +282,7 @@ class LaidOutOverflow extends LaidOutCell {
int _appointmentPriority(Appointment a) {
final id = a.id;
if (id is CustomAppointment) return 0;
if (id is WebuntisAppointment && id.lesson.code == 'cancelled') return 1;
if (id is LessonAppointment && id.entry.status == 'CANCELLED') return 1;
return 2;
}
@@ -9,6 +9,10 @@ class LessonColor {
static const Color irregular = Color(0xff8F19B3);
static const Color teacherChanged = Color(0xFF29639B);
static const Color event = Color(0xff2E7D32);
// Petrol-Türkis für Sonder-Lesson-Types (Aufsicht, Sprechstunde, …) —
// hebt sie deutlich von regulärem Unterricht (Marianum-Rot) und Events
// (Grün) ab, ohne den Status-übergreifenden Farb-Code zu verwässern.
static const Color duty = Color(0xff00796B);
static const Color parseFallback = Color(0xff404040);
static Color forStatus(LessonStatus status) {
@@ -21,6 +25,8 @@ class LessonColor {
return irregular;
case LessonStatus.teacherChanged:
return teacherChanged;
case LessonStatus.duty:
return duty;
case LessonStatus.past:
case LessonStatus.regular:
return regular;
@@ -0,0 +1,72 @@
import '../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
/// Combines back-to-back lessons with identical subject/room/teacher/status
/// into a single visual block. Shared by the calendar tile builder and the
/// home-widget data mapper so both surfaces show the same merged spans.
///
/// Built as a new list rather than mutating inputs — earlier in-place merges
/// extended merged blocks further on every rebuild when the same lesson
/// objects were observed again.
class LessonMerger {
const LessonMerger._();
static const Duration defaultMaxGap = Duration(minutes: 5);
static List<McTimetableEntry> merge(
List<McTimetableEntry> input, {
Duration maxGap = defaultMaxGap,
}) {
if (input.isEmpty) return const [];
final sorted = [...input]
..sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
final merged = <McTimetableEntry>[];
for (final current in sorted) {
if (merged.isNotEmpty && _canMerge(merged.last, current, maxGap)) {
final prev = merged.removeLast();
merged.add(_extendedEnd(prev, current.endTime));
} else {
merged.add(current);
}
}
return merged;
}
static bool _canMerge(
McTimetableEntry a,
McTimetableEntry b,
Duration maxGap,
) {
if (a.subjects.firstOrNull != b.subjects.firstOrNull) return false;
if (a.rooms.firstOrNull != b.rooms.firstOrNull) return false;
if (a.teachers.firstOrNull?.shortName !=
b.teachers.firstOrNull?.shortName) {
return false;
}
if (a.status != b.status) return false;
// Lower bound on the gap — without it, two identical-metadata lessons that
// overlap in time would silently collapse into one.
final gap = b.startDateTime.difference(a.endDateTime);
return !gap.isNegative && gap <= maxGap;
}
static McTimetableEntry _extendedEnd(
McTimetableEntry source,
DateTime newEndTime,
) => McTimetableEntry(
id: source.id,
date: source.date,
startTime: source.startTime,
endTime: newEndTime,
subjects: source.subjects,
teachers: source.teachers,
rooms: source.rooms,
classNames: source.classNames,
lessonType: source.lessonType,
status: source.status,
substitutionText: source.substitutionText,
lessonText: source.lessonText,
infoText: source.infoText,
);
}
@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import '../../../../api/webuntis/queries/get_timegrid_units/get_timegrid_units_response.dart';
import '../../../../api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid_response.dart';
import '../../../../state/app/modules/timetable/bloc/timetable_state.dart';
class LessonPeriod {
@@ -28,22 +28,30 @@ class LessonPeriodSchedule {
const LessonPeriodSchedule(this.periods);
static LessonPeriodSchedule? fromApi(GetTimegridUnitsResponse response) {
final canonical = response.result.firstWhere(
(d) => d.day == 1,
orElse: () => response.result.isNotEmpty
? response.result.first
: GetTimegridUnitsResponseDay(0, []),
);
if (canonical.timeUnits.isEmpty) return null;
static LessonPeriodSchedule? fromApi(TimetableGetTimegridResponse response) {
// The Marianum-Connect endpoint returns one entry per (weekday, unit). The
// school's bell schedule is identical MonFri, so we pick Monday as the
// canonical day and fall back to the first available weekday if Monday is
// missing.
final monday = response.result
.where((u) => u.dayOfWeek == McDayOfWeek.monday)
.toList();
final source = monday.isNotEmpty ? monday : response.result;
if (source.isEmpty) return null;
final periods =
canonical.timeUnits
source
.map(
(u) => LessonPeriod(
name: u.name,
start: _fromHHMM(u.startTime),
end: _fromHHMM(u.endTime),
name: u.label,
start: TimeOfDay(
hour: u.startTime.hour,
minute: u.startTime.minute,
),
end: TimeOfDay(
hour: u.endTime.hour,
minute: u.endTime.minute,
),
),
)
.toList()
@@ -144,7 +152,4 @@ class LessonPeriodSchedule {
}
return LessonPeriodSchedule(result);
}
static TimeOfDay _fromHHMM(int hhmm) =>
TimeOfDay(hour: hhmm ~/ 100, minute: hhmm % 100);
}
@@ -1,32 +1,40 @@
import '../../../../api/webuntis/queries/get_timetable/get_timetable_response.dart';
import '../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
enum LessonStatus {
cancelled,
event,
irregular,
teacherChanged,
duty,
past,
ongoing,
regular,
}
class LessonStatusClassifier {
/// Mirrors the legacy Webuntis classifier: cancelled trumps everything,
/// then event (subject-less lessons such as Wandertag), then irregular
/// (status from the backend or a slot without an assigned teacher), then
/// teacherChanged when the backend reports a substitution swap, then
/// duty (Aufsicht/Sprechstunde/…) so they stand out from regular
/// classroom lessons, then the time-based past/ongoing/regular states.
static LessonStatus classify(
GetTimetableResponseObject lesson,
McTimetableEntry entry,
DateTime startTime,
DateTime endTime,
DateTime now, {
bool isEvent = false,
bool isDuty = false,
}) {
if (lesson.code == 'cancelled') return LessonStatus.cancelled;
if (entry.status == 'CANCELLED') return LessonStatus.cancelled;
if (isEvent) return LessonStatus.event;
if (lesson.code == 'irregular' ||
(lesson.te.isNotEmpty && lesson.te.first.id == 0)) {
if (entry.status == 'IRREGULAR' || entry.teachers.isEmpty) {
return LessonStatus.irregular;
}
if (lesson.te.any((t) => t.orgname != null)) {
if (entry.teachers.any((t) => (t.originalShortName ?? '').isNotEmpty)) {
return LessonStatus.teacherChanged;
}
if (isDuty) return LessonStatus.duty;
if (endTime.isBefore(now)) return LessonStatus.past;
if (startTime.isBefore(now) && endTime.isAfter(now)) {
return LessonStatus.ongoing;
@@ -0,0 +1,33 @@
import '../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
/// Derives a human-readable title from the Marianum-Connect `lessonType` for
/// lessons that have no subject of their own (Aufsicht, Sprechstunde, …).
/// Shared between the calendar tile and the lesson detail sheet so both
/// surfaces show the same wording, with the room rendered first so it
/// survives a truncated tile.
class LessonTypeLabel {
static const String fallback = 'Event';
static String forEntry(McTimetableEntry lesson) {
final base = baseLabel(lesson.lessonType);
final room = (lesson.rooms.firstOrNull ?? '').trim();
return room.isEmpty ? base : '$room $base';
}
/// Returns just the type wording without the room — useful when the caller
/// renders the room separately (e.g. as a subtitle line).
static String baseLabel(String lessonType) {
switch (lessonType) {
case 'BREAK_SUPERVISION':
return 'Aufsicht';
case 'OFFICE_HOUR':
return 'Sprechstunde';
case 'STANDBY':
return 'Bereitschaft';
case 'EXAM':
return 'Prüfung';
default:
return fallback;
}
}
}
@@ -1,31 +1,27 @@
import 'package:collection/collection.dart';
import 'package:syncfusion_flutter_calendar/calendar.dart';
import '../../../../api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.dart';
import '../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
import '../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart';
import '../../../../api/webuntis/queries/get_rooms/get_rooms_response.dart';
import '../../../../api/webuntis/queries/get_subjects/get_subjects_response.dart';
import '../../../../api/webuntis/queries/get_timetable/get_timetable_response.dart';
import '../../../../storage/timetable_settings.dart';
import '../custom_events/custom_event_colors.dart';
import 'arbitrary_appointment.dart';
import 'lesson_color.dart';
import 'lesson_status.dart';
import 'lesson_type_label.dart';
import 'rrule_with_exceptions.dart';
import 'timetable_name_mode.dart';
import 'webuntis_time.dart';
class TimetableAppointmentFactory {
final List<GetTimetableResponseObject> lessons;
final List<McTimetableEntry> lessons;
final List<CustomTimetableEvent> customEvents;
final GetRoomsResponse rooms;
final GetSubjectsResponse subjects;
final List<McSubject> subjects;
final TimetableSettings settings;
final DateTime now;
TimetableAppointmentFactory({
required this.lessons,
required this.customEvents,
required this.rooms,
required this.subjects,
required this.settings,
required this.now,
@@ -41,37 +37,40 @@ class TimetableAppointmentFactory {
];
}
Appointment _lessonToAppointment(GetTimetableResponseObject lesson) {
Appointment _lessonToAppointment(McTimetableEntry lesson) {
try {
final startTime = WebuntisTime.parse(lesson.date, lesson.startTime);
final endTime = WebuntisTime.parse(lesson.date, lesson.endTime);
final subject = subjects.result.firstWhereOrNull(
(s) => s.id == lesson.su.firstOrNull?.id,
);
final startTime = lesson.startDateTime;
final endTime = lesson.endDateTime;
final subjectShortName = lesson.subjects.firstOrNull;
// "Event"-Status nur, wenn auch kein bekannter Sonder-Lesson-Type vorliegt
// — Aufsicht/Sprechstunde/etc. werden sonst grün statt eigenständig
// eingefärbt.
final isEvent = subjectShortName == null && lesson.lessonType == 'LESSON';
final isDuty = lesson.lessonType != 'LESSON';
final status = LessonStatusClassifier.classify(
lesson,
startTime,
endTime,
now,
isEvent: subject == null,
isEvent: isEvent,
isDuty: isDuty,
);
return Appointment(
id: WebuntisAppointment(lesson),
id: LessonAppointment(lesson),
startTime: startTime,
endTime: endTime,
subject: _subjectName(lesson, subject),
subject: _subjectName(subjectShortName, lesson),
location: _locationLabel(lesson),
notes: lesson.activityType,
color: LessonColor.forStatus(status),
);
} catch (_) {
return Appointment(
id: WebuntisAppointment(lesson),
startTime: WebuntisTime.parse(lesson.date, lesson.startTime),
endTime: WebuntisTime.parse(lesson.date, lesson.endTime),
id: LessonAppointment(lesson),
startTime: lesson.startDateTime,
endTime: lesson.endDateTime,
subject: 'Änderung',
notes: lesson.info,
notes: lesson.infoText,
location: 'Unbekannt',
color: LessonColor.parseFallback,
startTimeZone: '',
@@ -147,32 +146,48 @@ class TimetableAppointmentFactory {
e.second == 0;
}
String _subjectName(
GetTimetableResponseObject lesson,
GetSubjectsResponseObject? subject,
) {
if (subject == null) return 'Event';
final name = switch (settings.timetableNameMode) {
TimetableNameMode.name => subject.name,
TimetableNameMode.longName => subject.longName,
TimetableNameMode.alternateName => subject.alternateName,
};
return _collapseWhitespace(name) ?? 'Event';
String _subjectName(String? subjectShort, McTimetableEntry lesson) {
if (subjectShort != null) {
final lookup =
subjects.where((s) => s.shortName == subjectShort).firstOrNull;
final name = switch (settings.timetableNameMode) {
// Backend liefert nur shortName + longName; alternateName fällt auf
// longName zurück.
TimetableNameMode.name => subjectShort,
TimetableNameMode.longName => lookup?.longName ?? subjectShort,
TimetableNameMode.alternateName => lookup?.longName ?? subjectShort,
};
final collapsed = _collapseWhitespace(name);
if (collapsed != null) return collapsed;
}
// Subject leer → Titel aus dem Lesson-Type ableiten. Pausenaufsicht etc.
// sollen nicht generisch als "Event" auftauchen, sondern ihren Zweck samt
// Ort tragen.
return LessonTypeLabel.forEntry(lesson);
}
String _locationLabel(GetTimetableResponseObject lesson) {
String _locationLabel(McTimetableEntry lesson) {
final roomName =
_collapseWhitespace(
rooms.result
.firstWhereOrNull((r) => r.id == lesson.ro.firstOrNull?.id)
?.name,
) ??
'Unbekannt';
_collapseWhitespace(lesson.rooms.firstOrNull) ?? 'Unbekannt';
final teacherName =
_collapseWhitespace(lesson.te.firstOrNull?.longname) ?? 'Unbekannt';
_teacherLabel(lesson.teachers.firstOrNull) ?? 'Unbekannt';
return '$roomName\n$teacherName';
}
/// Backend serves teachers with their full display name ("Stefan Müller"),
/// which doesn't fit into a single calendar tile alongside the room. We
/// reduce it to the last whitespace-separated token (the surname) for the
/// overview; the detail sheet still renders the full name as a subtitle.
static String? _teacherLabel(McTimetableTeacher? teacher) {
if (teacher == null) return null;
final display = _collapseWhitespace(teacher.displayName);
if (display != null && display.isNotEmpty) {
final parts = display.split(' ');
return parts.isEmpty ? display : parts.last;
}
return _collapseWhitespace(teacher.shortName);
}
/// Collapses any line-break or whitespace run to a single space and trims.
/// Returns null when input is null or fully whitespace. Webuntis sometimes
/// returns multi-line room names like "A30\n4" — this normalizes those so
@@ -189,66 +204,67 @@ class TimetableAppointmentFactory {
return cleaned.isEmpty ? null : cleaned;
}
// Pure: returns a new list of fresh objects, does not mutate input.
// (The previous version replaced `previous.endTime` in place, which
// mutated the original lesson object passed in via [input]. Across
// rebuilds those mutated lessons were observed again by the next merge
// pass — extending lessons further or, after the overlap-gap guard was
// added to [_canMerge], even causing the second half of a double lesson
// to be emitted alongside the already-merged block.)
static List<GetTimetableResponseObject> _mergeAdjacentLessons(
List<GetTimetableResponseObject> input, {
// Pure: builds a new list, does not mutate inputs. The previous version
// mutated `previous.endTime` in place which caused merged blocks to grow
// further on subsequent rebuilds when the same lesson objects were observed
// again by the next merge pass.
static List<McTimetableEntry> _mergeAdjacentLessons(
List<McTimetableEntry> input, {
Duration maxGap = const Duration(minutes: 5),
}) {
if (input.isEmpty) return const [];
final sorted = [...input]
..sort(
(a, b) => WebuntisTime.parse(
a.date,
a.startTime,
).compareTo(WebuntisTime.parse(b.date, b.startTime)),
);
..sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
final merged = <GetTimetableResponseObject>[];
final merged = <McTimetableEntry>[];
for (final current in sorted) {
if (merged.isNotEmpty && _canMerge(merged.last, current, maxGap)) {
// `merged.last` is always a copy we created below, so mutating its
// endTime is safe and keeps the next iteration's gap check correct.
merged.last.endTime = current.endTime;
final prev = merged.removeLast();
merged.add(_extendedEnd(prev, current.endTime));
} else {
merged.add(_copyLesson(current));
merged.add(current);
}
}
return merged;
}
static GetTimetableResponseObject _copyLesson(GetTimetableResponseObject l) =>
GetTimetableResponseObject.fromJson(l.toJson());
static McTimetableEntry _extendedEnd(
McTimetableEntry source,
DateTime newEndTime,
) => McTimetableEntry(
id: source.id,
date: source.date,
startTime: source.startTime,
endTime: newEndTime,
subjects: source.subjects,
teachers: source.teachers,
rooms: source.rooms,
classNames: source.classNames,
lessonType: source.lessonType,
status: source.status,
substitutionText: source.substitutionText,
lessonText: source.lessonText,
infoText: source.infoText,
);
static bool _canMerge(
GetTimetableResponseObject a,
GetTimetableResponseObject b,
McTimetableEntry a,
McTimetableEntry b,
Duration maxGap,
) {
final aSubject = a.su.firstOrNull?.id;
final bSubject = b.su.firstOrNull?.id;
if (aSubject == null || bSubject == null || aSubject != bSubject) {
if (a.subjects.firstOrNull != b.subjects.firstOrNull) return false;
if (a.rooms.firstOrNull != b.rooms.firstOrNull) return false;
if (a.teachers.firstOrNull?.shortName !=
b.teachers.firstOrNull?.shortName) {
return false;
}
if (a.ro.firstOrNull?.id != b.ro.firstOrNull?.id) return false;
if (a.te.firstOrNull?.id != b.te.firstOrNull?.id) return false;
if (a.code != b.code) return false;
if (a.status != b.status) return false;
// Merge only sequential lessons (b starts at or after a ends, within the
// tolerance). Without the lower bound, identical-metadata lessons that
// overlap in time would silently collapse into one — and because the
// merge sets `previous.endTime = current.endTime`, an overlapping merge
// can even truncate the earlier lesson.
final gap = WebuntisTime.parse(
b.date,
b.startTime,
).difference(WebuntisTime.parse(a.date, a.endTime));
// overlap in time would silently collapse into one.
final gap = b.startDateTime.difference(a.endDateTime);
return !gap.isNegative && gap <= maxGap;
}
}
@@ -1,16 +0,0 @@
import 'package:intl/intl.dart';
class WebuntisTime {
static final DateFormat _dateFormat = DateFormat('yyyyMMdd');
static DateTime parse(int date, int time) {
final timeString = time.toString().padLeft(4, '0');
return DateTime.parse(
'$date ${timeString.substring(0, 2)}:${timeString.substring(2, 4)}',
);
}
static int formatDate(DateTime date) => int.parse(_dateFormat.format(date));
static String dateKey(DateTime date) => _dateFormat.format(date);
}