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 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 = [ ...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 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 = [ ...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( 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 = []; 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 _mergePerDay( List lessons, ) { final byDay = >{}; 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 _mergeAdjacentLessons( List 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 = []; 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 _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; }