Files
Client/lib/widget_data/widget_data_mapper.dart
T

473 lines
16 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/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_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 {
/// 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<GetTimetableResponseObject> lessons,
required GetSubjectsResponse? subjects,
required GetRoomsResponse? rooms,
required GetHolidaysResponse? holidays,
GetTimegridUnitsResponse? 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
? _mergeAdjacentLessons(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<GetTimetableResponseObject> lessons,
required GetSubjectsResponse? subjects,
required GetRoomsResponse? rooms,
required GetHolidaysResponse? holidays,
GetTimegridUnitsResponse? 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);
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(
GetTimegridUnitsResponse? 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;
}
static List<GetTimetableResponseObject> _mergePerDay(
List<GetTimetableResponseObject> lessons,
) {
final byDay = <int, List<GetTimetableResponseObject>>{};
for (final l in lessons) {
byDay.putIfAbsent(l.date, () => []).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;
}
static WidgetLesson _mapLesson(
GetTimetableResponseObject lesson,
DateTime now,
GetSubjectsResponse? subjects,
GetRoomsResponse? rooms,
) {
final start = WebuntisTime.parse(lesson.date, lesson.startTime);
final end = WebuntisTime.parse(lesson.date, lesson.endTime);
final status = _mapStatus(
LessonStatusClassifier.classify(lesson, start, end, now),
);
final subject = lesson.su.firstOrNull;
// 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;
String? subjectLong;
if (subjects != null && subject != null) {
final found = subjects.result.where((s) => s.id == subject.id).firstOrNull;
subjectLong = found?.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 teacher = lesson.te.firstOrNull;
final teacherName = teacher?.id == 0 ? null : teacher?.name;
final originalTeacher = teacher?.orgname;
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.past:
return WidgetLessonStatus.past;
case LessonStatus.ongoing:
return WidgetLessonStatus.ongoing;
case LessonStatus.regular:
return WidgetLessonStatus.regular;
}
}
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 GetHolidaysResponseObject? _findHoliday(
DateTime day,
GetHolidaysResponse? holidays,
) {
if (holidays == null) return null;
final asInt = WebuntisTime.formatDate(day);
for (final h in holidays.result) {
if (asInt >= h.startDate && asInt <= h.endDate) 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;
}