fixed lesson merging mutation, improved overlap detection, and implemented priority-based lane assignment with tablet support

This commit is contained in:
2026-05-07 13:27:40 +02:00
parent c32e64fe74
commit 3b1b0d0c19
2 changed files with 67 additions and 26 deletions
@@ -148,7 +148,13 @@ class TimetableAppointmentFactory {
return cleaned.isEmpty ? null : cleaned;
}
// Pure: returns a new list, does not mutate input.
// Pure: returns a new list of fresh objects, does not mutate input.
// (The previous version replaced `previous.endTime` in place, which
// mutated the original lesson object passed in via [input]. Across
// rebuilds those mutated lessons were observed again by the next merge
// pass — extending lessons further or, after the overlap-gap guard was
// added to [_canMerge], even causing the second half of a double lesson
// to be emitted alongside the already-merged block.)
static List<GetTimetableResponseObject> _mergeAdjacentLessons(
List<GetTimetableResponseObject> input, {
Duration maxGap = const Duration(minutes: 5),
@@ -158,19 +164,22 @@ class TimetableAppointmentFactory {
final sorted = [...input]..sort((a, b) =>
WebuntisTime.parse(a.date, a.startTime).compareTo(WebuntisTime.parse(b.date, b.startTime)));
final merged = <GetTimetableResponseObject>[sorted.first];
for (var i = 1; i < sorted.length; i++) {
final previous = merged.last;
final current = sorted[i];
if (_canMerge(previous, current, maxGap)) {
previous.endTime = current.endTime;
final merged = <GetTimetableResponseObject>[];
for (final current in sorted) {
if (merged.isNotEmpty && _canMerge(merged.last, current, maxGap)) {
// `merged.last` is always a copy we created below, so mutating its
// endTime is safe and keeps the next iteration's gap check correct.
merged.last.endTime = current.endTime;
} else {
merged.add(current);
merged.add(_copyLesson(current));
}
}
return merged;
}
static GetTimetableResponseObject _copyLesson(GetTimetableResponseObject l) =>
GetTimetableResponseObject.fromJson(l.toJson());
static bool _canMerge(GetTimetableResponseObject a, GetTimetableResponseObject b, Duration maxGap) {
final aSubject = a.su.firstOrNull?.id;
final bSubject = b.su.firstOrNull?.id;
@@ -179,7 +188,12 @@ class TimetableAppointmentFactory {
if (a.te.firstOrNull?.id != b.te.firstOrNull?.id) return false;
if (a.code != b.code) return false;
// Merge only sequential lessons (b starts at or after a ends, within the
// tolerance). Without the lower bound, identical-metadata lessons that
// overlap in time would silently collapse into one — and because the
// merge sets `previous.endTime = current.endTime`, an overlapping merge
// can even truncate the earlier lesson.
final gap = WebuntisTime.parse(b.date, b.startTime).difference(WebuntisTime.parse(a.date, a.endTime));
return gap <= maxGap;
return !gap.isNegative && gap <= maxGap;
}
}