Files
Client/lib/widget_data/widget_data_mapper.dart
T

438 lines
15 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 '../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 'widget_data.dart';
class WidgetDataMapper {
/// After 17:00 the user's question shifts from "what's left today" to
/// "what's tomorrow", so the day-widget rolls forward.
static const int _dayWidgetCutoffHour = 17;
static const _weekend = {DateTime.saturday, DateTime.sunday};
static DateTime resolveDayAnchor(DateTime now) {
var candidate = DateTime(now.year, now.month, now.day);
final shiftToTomorrow =
now.hour >= _dayWidgetCutoffHour || _weekend.contains(now.weekday);
if (shiftToTomorrow) {
candidate = candidate.add(const Duration(days: 1));
}
while (_weekend.contains(candidate.weekday)) {
candidate = candidate.add(const Duration(days: 1));
}
return candidate;
}
static DateTime resolveWeekAnchor(DateTime now) {
final anchor = resolveDayAnchor(now);
final monday = anchor.subtract(Duration(days: anchor.weekday - 1));
return DateTime(monday.year, monday.month, monday.day);
}
static WidgetTimetableData buildDayData({
required DateTime now,
required Iterable<McTimetableEntry> lessons,
required TimetableGetSubjectsResponse? subjects,
required TimetableGetRoomsResponse? rooms,
required TimetableGetHolidaysResponse? holidays,
TimetableGetTimegridResponse? timegrid,
GetCustomTimetableEventResponse? customEvents,
bool connectDoubleLessons = true,
}) {
final anchor = resolveDayAnchor(now);
final holiday = _findHoliday(anchor, holidays);
final dayStart = anchor;
final dayEnd = anchor.add(const Duration(days: 1));
final dayLessons = lessons.where((l) => _onSameDay(l, anchor)).toList();
final source = connectDoubleLessons
? LessonMerger.merge(dayLessons)
: dayLessons;
final mapped = <WidgetLesson>[
...source.map((l) => _mapLesson(l, now, subjects, rooms)),
..._expandCustomEvents(customEvents, dayStart, dayEnd),
]..sort((a, b) => a.start.compareTo(b.start));
return WidgetTimetableData(
fetchedAt: now,
anchorDate: anchor,
lessons: _resolveCollisions(mapped),
periods: _resolvePeriods(timegrid),
isHoliday: holiday != null,
holidayName: holiday?.longName,
);
}
static WidgetTimetableData buildWeekData({
required DateTime now,
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 = l.startDateTime;
return !dt.isBefore(anchor) && dt.isBefore(endExclusive);
}).toList();
// Per-day merge: otherwise a 4th-period lesson on Mon would collapse with
// a 1st-period lesson on Tue if subject/teacher match.
final source = connectDoubleLessons
? _mergePerDay(weekLessons)
: weekLessons;
final mapped = <WidgetLesson>[
...source.map((l) => _mapLesson(l, now, subjects, rooms)),
..._expandCustomEvents(customEvents, anchor, endExclusive),
]..sort((a, b) => a.start.compareTo(b.start));
return WidgetTimetableData(
fetchedAt: now,
anchorDate: anchor,
lessons: _resolveCollisions(mapped),
periods: _resolvePeriods(timegrid),
);
}
/// cancelled (0) < event (1) < regular (2) — events replace cancelled
/// lessons but lose to real ones, leaving a `+1` hint on the survivor.
static int _priority(WidgetLessonStatus status) => switch (status) {
WidgetLessonStatus.cancelled => 0,
WidgetLessonStatus.event => 1,
_ => 2,
};
static List<WidgetLesson> _resolveCollisions(List<WidgetLesson> lessons) {
if (lessons.length <= 1) return lessons;
bool overlaps(WidgetLesson l, WidgetLesson other) =>
l != other && l.start.isBefore(other.end) && l.end.isAfter(other.start);
// Index-based: a long event covering several regulars must bump *every*
// covered lesson, not just the first overlap.
final dropped = List<bool>.filled(lessons.length, false);
final bumps = List<int>.filled(lessons.length, 0);
for (var i = 0; i < lessons.length; i++) {
final l = lessons[i];
final myPrio = _priority(l.status);
final overrideIdxs = <int>[];
for (var j = 0; j < lessons.length; j++) {
if (i == j) continue;
if (_priority(lessons[j].status) <= myPrio) continue;
if (!overlaps(l, lessons[j])) continue;
overrideIdxs.add(j);
}
if (overrideIdxs.isNotEmpty) {
dropped[i] = true;
if (l.status == WidgetLessonStatus.event) {
for (final idx in overrideIdxs) {
bumps[idx] += 1;
}
}
}
}
final filtered = <WidgetLesson>[];
for (var i = 0; i < lessons.length; i++) {
if (dropped[i]) continue;
final l = lessons[i];
filtered.add(
bumps[i] > 0
? l.copyWith(siblingCount: l.siblingCount + bumps[i])
: l,
);
}
if (filtered.length <= 1) return filtered;
final groups = <String, List<WidgetLesson>>{};
for (final l in filtered) {
final key =
'${l.start.year}-${l.start.month}-${l.start.day}-${l.start.hour}-${l.start.minute}';
groups.putIfAbsent(key, () => []).add(l);
}
final result = <WidgetLesson>[];
for (final group in groups.values) {
if (group.length == 1) {
result.add(group.first);
continue;
}
final active = group
.where((l) => l.status != WidgetLessonStatus.cancelled)
.toList();
if (active.isEmpty) {
result.addAll(group);
continue;
}
active.sort((a, b) => a.subjectShort.compareTo(b.subjectShort));
// Additive — preserves the event-bump from the priority pass, otherwise
// a slot with another regular lesson AND a hidden event would show +1
// instead of +2.
final keeper = active.first;
result.add(
keeper.copyWith(
siblingCount: keeper.siblingCount + active.length - 1,
),
);
}
return result..sort((a, b) => a.start.compareTo(b.start));
}
/// Gaps below this collapse to zero on the virtual axis so 45-min slots
/// stack flush; bigger gaps survive as visible Pause-blocks.
static const int _smallBreakThresholdMinutes = 5;
static List<WidgetPeriod> _resolvePeriods(
TimetableGetTimegridResponse? timegrid,
) {
final schedule =
(timegrid != null ? LessonPeriodSchedule.fromApi(timegrid) : null) ??
LessonPeriodSchedule.fallback();
final raw = schedule.periods
.map(
(p) => (
name: p.name,
start: p.start.hour * 60 + p.start.minute,
end: p.end.hour * 60 + p.end.minute,
),
)
.toList()
..sort((a, b) => a.start.compareTo(b.start));
final result = <WidgetPeriod>[];
var virtualOffset = 0;
int? prevEnd;
for (final p in raw) {
if (prevEnd != null) {
final gap = p.start - prevEnd;
if (gap > _smallBreakThresholdMinutes) virtualOffset += gap;
}
final duration = p.end - p.start;
result.add(
WidgetPeriod(
name: p.name,
startMinutes: p.start,
endMinutes: p.end,
virtualStartMinutes: virtualOffset,
virtualEndMinutes: virtualOffset + duration,
),
);
virtualOffset += duration;
prevEnd = p.end;
}
return result;
}
// 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) {
final key = '${l.date.year}-${l.date.month}-${l.date.day}';
byDay.putIfAbsent(key, () => []).add(l);
}
return [for (final group in byDay.values) ...LessonMerger.merge(group)];
}
static WidgetLesson _mapLesson(
McTimetableEntry lesson,
DateTime now,
TimetableGetSubjectsResponse? subjects,
TimetableGetRoomsResponse? rooms,
) {
final start = lesson.startDateTime;
final end = lesson.endDateTime;
final status = _mapStatus(
LessonStatusClassifier.classify(lesson, start, end, now),
);
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 subjectShort = subjectShortRaw.isEmpty ? 'Event' : subjectShortRaw;
String? subjectLong;
if (subjects != null && subjectShortRaw.isNotEmpty) {
subjectLong = subjects.result
.where((s) => s.shortName == subjectShortRaw)
.firstOrNull
?.longName;
}
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.teachers.firstOrNull;
final teacherName = teacher?.shortName;
final originalTeacher = teacher?.originalShortName;
return WidgetLesson(
start: start,
end: end,
subjectShort: subjectShort,
subjectLong: subjectLong,
room: roomName,
teacher: teacherName,
originalTeacher: originalTeacher,
status: status,
);
}
static WidgetLessonStatus _mapStatus(LessonStatus status) {
switch (status) {
case LessonStatus.cancelled:
return WidgetLessonStatus.cancelled;
case LessonStatus.event:
return WidgetLessonStatus.event;
case LessonStatus.irregular:
return WidgetLessonStatus.irregular;
case LessonStatus.teacherChanged:
return WidgetLessonStatus.teacherChanged;
case LessonStatus.duty:
return WidgetLessonStatus.duty;
case LessonStatus.past:
return WidgetLessonStatus.past;
case LessonStatus.ongoing:
return WidgetLessonStatus.ongoing;
case LessonStatus.regular:
return WidgetLessonStatus.regular;
}
}
static bool _onSameDay(McTimetableEntry lesson, DateTime day) {
return lesson.date.year == day.year &&
lesson.date.month == day.month &&
lesson.date.day == day.day;
}
static McHoliday? _findHoliday(
DateTime day,
TimetableGetHolidaysResponse? holidays,
) {
if (holidays == null) return null;
final asDay = DateTime(day.year, day.month, day.day);
for (final h in holidays.result) {
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;
}
static Iterable<WidgetLesson> _expandCustomEvents(
GetCustomTimetableEventResponse? customEvents,
DateTime rangeStart,
DateTime rangeEndExclusive,
) sync* {
if (customEvents == null) return;
final rangeStartUtc = rangeStart.toUtc();
final rangeEndUtc = rangeEndExclusive.toUtc();
for (final event in customEvents.events) {
yield* _expandSingleEvent(event, rangeStartUtc, rangeEndUtc);
}
}
static Iterable<WidgetLesson> _expandSingleEvent(
CustomTimetableEvent event,
DateTime rangeStartUtc,
DateTime rangeEndUtc,
) sync* {
final rule = event.rrule;
final duration = event.endDate.difference(event.startDate);
if (rule.isEmpty) {
final startUtc = event.startDate.toUtc();
if (startUtc.isBefore(rangeStartUtc) ||
!startUtc.isBefore(rangeEndUtc)) {
return;
}
yield* _customEventToWidgetLessons(event, event.startDate, duration);
return;
}
try {
final parsed = RecurrenceRule.fromString(rule);
final anchorUtc = event.startDate.toUtc();
for (final occUtc in parsed.getInstances(start: anchorUtc)) {
if (!occUtc.isBefore(rangeEndUtc)) break;
if (occUtc.isBefore(rangeStartUtc)) continue;
final occLocal = occUtc.toLocal();
final occStart = DateTime(
occLocal.year,
occLocal.month,
occLocal.day,
event.startDate.hour,
event.startDate.minute,
);
yield* _customEventToWidgetLessons(event, occStart, duration);
}
} on Exception catch (e) {
log('Widget mapper: invalid rrule "$rule" on event ${event.id}: $e');
}
}
/// Splits multi-day events into one block per local calendar day, so each
/// affected day on the week-widget shows the event. All-day events
/// (start = end = midnight) collapse to a single 00:0023:59 block.
static Iterable<WidgetLesson> _customEventToWidgetLessons(
CustomTimetableEvent event,
DateTime occurrenceStart,
Duration duration,
) sync* {
final title = event.title.trim();
WidgetLesson buildBlock(DateTime start, DateTime end) => WidgetLesson(
start: start,
end: end,
subjectShort: title.isEmpty ? 'Termin' : title,
subjectLong: title.isEmpty ? null : title,
status: WidgetLessonStatus.event,
customColor: event.color,
);
final isAllDay = duration == Duration.zero && _isMidnight(event.startDate);
if (isAllDay) {
yield buildBlock(
occurrenceStart,
DateTime(
occurrenceStart.year,
occurrenceStart.month,
occurrenceStart.day,
23,
59,
),
);
return;
}
final actualEnd = occurrenceStart.add(duration);
var segmentStart = occurrenceStart;
while (segmentStart.isBefore(actualEnd)) {
final nextMidnight = DateTime(
segmentStart.year,
segmentStart.month,
segmentStart.day,
).add(const Duration(days: 1));
final segmentEnd = actualEnd.isBefore(nextMidnight)
? actualEnd
: nextMidnight.subtract(const Duration(minutes: 1));
yield buildBlock(segmentStart, segmentEnd);
segmentStart = nextMidnight;
}
}
static bool _isMidnight(DateTime d) =>
d.hour == 0 && d.minute == 0 && d.second == 0;
}