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
+62 -97
View File
@@ -2,16 +2,16 @@ import 'dart:developer';
import 'package:rrule/rrule.dart';
import '../api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays_response.dart';
import '../api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms_response.dart';
import '../api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.dart';
import '../api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid_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/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart';
import '../api/webuntis/queries/get_holidays/get_holidays_response.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_timegrid_units/get_timegrid_units_response.dart';
import '../api/webuntis/queries/get_timetable/get_timetable_response.dart';
import '../view/pages/timetable/data/lesson_merger.dart';
import '../view/pages/timetable/data/lesson_period_schedule.dart';
import '../view/pages/timetable/data/lesson_status.dart';
import '../view/pages/timetable/data/webuntis_time.dart';
import 'widget_data.dart';
class WidgetDataMapper {
@@ -42,11 +42,11 @@ class WidgetDataMapper {
static WidgetTimetableData buildDayData({
required DateTime now,
required Iterable<GetTimetableResponseObject> lessons,
required GetSubjectsResponse? subjects,
required GetRoomsResponse? rooms,
required GetHolidaysResponse? holidays,
GetTimegridUnitsResponse? timegrid,
required Iterable<McTimetableEntry> lessons,
required TimetableGetSubjectsResponse? subjects,
required TimetableGetRoomsResponse? rooms,
required TimetableGetHolidaysResponse? holidays,
TimetableGetTimegridResponse? timegrid,
GetCustomTimetableEventResponse? customEvents,
bool connectDoubleLessons = true,
}) {
@@ -56,7 +56,7 @@ class WidgetDataMapper {
final dayEnd = anchor.add(const Duration(days: 1));
final dayLessons = lessons.where((l) => _onSameDay(l, anchor)).toList();
final source = connectDoubleLessons
? _mergeAdjacentLessons(dayLessons)
? LessonMerger.merge(dayLessons)
: dayLessons;
final mapped = <WidgetLesson>[
...source.map((l) => _mapLesson(l, now, subjects, rooms)),
@@ -74,18 +74,18 @@ class WidgetDataMapper {
static WidgetTimetableData buildWeekData({
required DateTime now,
required Iterable<GetTimetableResponseObject> lessons,
required GetSubjectsResponse? subjects,
required GetRoomsResponse? rooms,
required GetHolidaysResponse? holidays,
GetTimegridUnitsResponse? timegrid,
required Iterable<McTimetableEntry> lessons,
required TimetableGetSubjectsResponse? subjects,
required TimetableGetRoomsResponse? rooms,
required TimetableGetHolidaysResponse? holidays,
TimetableGetTimegridResponse? timegrid,
GetCustomTimetableEventResponse? customEvents,
bool connectDoubleLessons = true,
}) {
final anchor = resolveWeekAnchor(now);
final endExclusive = anchor.add(const Duration(days: 5));
final weekLessons = lessons.where((l) {
final dt = WebuntisTime.parse(l.date, l.startTime);
final dt = l.startDateTime;
return !dt.isBefore(anchor) && dt.isBefore(endExclusive);
}).toList();
// Per-day merge: otherwise a 4th-period lesson on Mon would collapse with
@@ -192,7 +192,7 @@ class WidgetDataMapper {
static const int _smallBreakThresholdMinutes = 5;
static List<WidgetPeriod> _resolvePeriods(
GetTimegridUnitsResponse? timegrid,
TimetableGetTimegridResponse? timegrid,
) {
final schedule =
(timegrid != null ? LessonPeriodSchedule.fromApi(timegrid) : null) ??
@@ -232,92 +232,52 @@ class WidgetDataMapper {
return result;
}
static List<GetTimetableResponseObject> _mergePerDay(
List<GetTimetableResponseObject> lessons,
) {
final byDay = <int, List<GetTimetableResponseObject>>{};
// Per-Tag-Merge: ohne diese Gruppierung würde eine letzte Stunde am Montag
// mit der ersten Stunde am Dienstag verschmelzen, wenn Fach + Lehrer
// identisch sind.
static List<McTimetableEntry> _mergePerDay(List<McTimetableEntry> lessons) {
final byDay = <String, List<McTimetableEntry>>{};
for (final l in lessons) {
byDay.putIfAbsent(l.date, () => []).add(l);
final key = '${l.date.year}-${l.date.month}-${l.date.day}';
byDay.putIfAbsent(key, () => []).add(l);
}
return [for (final group in byDay.values) ..._mergeAdjacentLessons(group)];
}
/// Mirrors `TimetableAppointmentFactory._mergeAdjacentLessons` so the
/// widget shows the same merged blocks the in-app calendar does.
static List<GetTimetableResponseObject> _mergeAdjacentLessons(
List<GetTimetableResponseObject> 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)),
);
final merged = <GetTimetableResponseObject>[];
for (final current in sorted) {
if (merged.isNotEmpty && _canMerge(merged.last, current, maxGap)) {
merged.last.endTime = current.endTime;
} else {
merged.add(GetTimetableResponseObject.fromJson(current.toJson()));
}
}
return merged;
}
static bool _canMerge(
GetTimetableResponseObject a,
GetTimetableResponseObject b,
Duration maxGap,
) {
final aSubject = a.su.firstOrNull?.id;
final bSubject = b.su.firstOrNull?.id;
if (aSubject == null || bSubject == null || aSubject != bSubject) {
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;
final gap = WebuntisTime.parse(
b.date,
b.startTime,
).difference(WebuntisTime.parse(a.date, a.endTime));
return !gap.isNegative && gap <= maxGap;
return [for (final group in byDay.values) ...LessonMerger.merge(group)];
}
static WidgetLesson _mapLesson(
GetTimetableResponseObject lesson,
McTimetableEntry lesson,
DateTime now,
GetSubjectsResponse? subjects,
GetRoomsResponse? rooms,
TimetableGetSubjectsResponse? subjects,
TimetableGetRoomsResponse? rooms,
) {
final start = WebuntisTime.parse(lesson.date, lesson.startTime);
final end = WebuntisTime.parse(lesson.date, lesson.endTime);
final start = lesson.startDateTime;
final end = lesson.endDateTime;
final status = _mapStatus(
LessonStatusClassifier.classify(lesson, start, end, now),
);
final subject = lesson.su.firstOrNull;
final subjectShortRaw = lesson.subjects.firstOrNull?.trim() ?? '';
// Webuntis sometimes ships subject-less entries (Wandertag etc.). Fall
// back to "Event" so the tile isn't just a dash.
final rawSubjectName = subject?.name.trim() ?? '';
final subjectShort = rawSubjectName.isEmpty ? 'Event' : rawSubjectName;
final subjectShort = subjectShortRaw.isEmpty ? 'Event' : subjectShortRaw;
String? subjectLong;
if (subjects != null && subject != null) {
final found = subjects.result.where((s) => s.id == subject.id).firstOrNull;
subjectLong = found?.longName;
if (subjects != null && subjectShortRaw.isNotEmpty) {
subjectLong = subjects.result
.where((s) => s.shortName == subjectShortRaw)
.firstOrNull
?.longName;
}
subjectLong ??= subject?.longname;
final room = lesson.ro.firstOrNull;
var roomName = room?.name;
if (rooms != null && room != null) {
final resolved =
rooms.result.where((r) => r.id == room.id).firstOrNull?.name;
roomName = resolved ?? roomName;
final roomShort = lesson.rooms.firstOrNull;
var roomName = roomShort;
if (rooms != null && roomShort != null) {
roomName = rooms.result
.where((r) => r.shortName == roomShort)
.firstOrNull
?.shortName ??
roomName;
}
final teacher = lesson.te.firstOrNull;
final teacherName = teacher?.id == 0 ? null : teacher?.name;
final originalTeacher = teacher?.orgname;
final teacher = lesson.teachers.firstOrNull;
final teacherName = teacher?.shortName;
final originalTeacher = teacher?.originalShortName;
return WidgetLesson(
start: start,
end: end,
@@ -340,6 +300,8 @@ class WidgetDataMapper {
return WidgetLessonStatus.irregular;
case LessonStatus.teacherChanged:
return WidgetLessonStatus.teacherChanged;
case LessonStatus.duty:
return WidgetLessonStatus.duty;
case LessonStatus.past:
return WidgetLessonStatus.past;
case LessonStatus.ongoing:
@@ -349,19 +311,22 @@ class WidgetDataMapper {
}
}
static bool _onSameDay(GetTimetableResponseObject lesson, DateTime day) {
final dt = WebuntisTime.parse(lesson.date, lesson.startTime);
return dt.year == day.year && dt.month == day.month && dt.day == day.day;
static bool _onSameDay(McTimetableEntry lesson, DateTime day) {
return lesson.date.year == day.year &&
lesson.date.month == day.month &&
lesson.date.day == day.day;
}
static GetHolidaysResponseObject? _findHoliday(
static McHoliday? _findHoliday(
DateTime day,
GetHolidaysResponse? holidays,
TimetableGetHolidaysResponse? holidays,
) {
if (holidays == null) return null;
final asInt = WebuntisTime.formatDate(day);
final asDay = DateTime(day.year, day.month, day.day);
for (final h in holidays.result) {
if (asInt >= h.startDate && asInt <= h.endDate) return h;
final start = DateTime(h.startDate.year, h.startDate.month, h.startDate.day);
final end = DateTime(h.endDate.year, h.endDate.month, h.endDate.day);
if (!asDay.isBefore(start) && !asDay.isAfter(end)) return h;
}
return null;
}