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 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 = [ ...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 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 = [ ...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 _resolveCollisions(List 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.filled(lessons.length, false); final bumps = List.filled(lessons.length, 0); for (var i = 0; i < lessons.length; i++) { final l = lessons[i]; final myPrio = _priority(l.status); final overrideIdxs = []; 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 = []; 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 = >{}; 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 = []; 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 _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 = []; 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 _mergePerDay(List lessons) { final byDay = >{}; 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 _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 _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:00–23:59 block. static Iterable _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; }