473 lines
16 KiB
Dart
473 lines
16 KiB
Dart
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:00–23: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;
|
||
}
|