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;
}
}
@@ -7,6 +7,7 @@ import 'package:intl/intl.dart';
import 'package:rrule/rrule.dart';
import 'package:syncfusion_flutter_calendar/calendar.dart';
import '../data/arbitrary_appointment.dart';
import '../data/calendar_layout.dart';
import '../data/lesson_period_schedule.dart';
import 'appointment_tile.dart';
@@ -853,7 +854,8 @@ class _DayColumn extends StatelessWidget {
final dayRegions = _expandRegionsForDay(timeRegions, date);
final isToday = _isSameDay(date, today);
final laidOut = _assignLanes(dayAppointments);
final isTablet = MediaQuery.of(context).size.shortestSide >= 600;
final laidOut = _assignLanes(dayAppointments, maxLanes: isTablet ? 3 : 2);
return GestureDetector(
behavior: HitTestBehavior.translucent,
@@ -1209,11 +1211,6 @@ class _PeriodLayout {
}
}
/// Maximum number of cells shown side by side in a single time slot. When a
/// cluster needs more lanes than this, the first appointment (by start time)
/// keeps lane 0 and the rest are collapsed into a single "+N" overflow cell
/// in lane 1.
const int _kMaxVisibleCells = 2;
class _OverflowTile extends StatelessWidget {
final int count;
@@ -1324,22 +1321,40 @@ class _LaidOutOverflow extends _LaidOutCell {
_LaidOutOverflow(this.appointments, this.lane, this.laneCount, this.startTime, this.endTime);
}
/// Horizontal ordering rank for parallel appointments. Lower = further left.
/// User-owned custom events sit on the leftmost lane, cancelled lessons after
/// them, every other lesson last. Only used as a tiebreaker — the greedy lane
/// assignment still has to honor actual time-overlap constraints, so events
/// that start later can't jump left of events that started earlier and are
/// still occupying that lane.
int _appointmentPriority(Appointment a) {
final id = a.id;
if (id is CustomAppointment) return 0;
if (id is WebuntisAppointment && id.lesson.code == 'cancelled') return 1;
return 2;
}
/// Assigns each appointment a lane index using a greedy sweep, then collapses
/// clusters that exceed [_kMaxVisibleCells] into 1 visible appointment + 1
/// overflow cell side by side.
/// clusters that exceed [maxLanes] into `(maxLanes - 1)` visible appointments
/// + one trailing overflow cell.
///
/// Greedy sweep:
/// 1. Sort by `startTime` ascending, `endTime` descending on ties.
/// 1. Sort by `startTime` ascending, then [_appointmentPriority] (custom →
/// cancelled → other) so parallel events land in the requested left-to-
/// right order, then `endTime` descending as a final tiebreaker.
/// 2. Walk the list, placing each appointment in the lowest-index lane that
/// is free at its `startTime`. When no lane is free, open a new one.
/// 3. A cluster ends as soon as every active lane's end is at or before the
/// next appointment's start.
List<_LaidOutCell> _assignLanes(List<Appointment> appts) {
List<_LaidOutCell> _assignLanes(List<Appointment> appts, {required int maxLanes}) {
assert(maxLanes >= 2, 'maxLanes must reserve at least one slot for overflow');
if (appts.isEmpty) return const <_LaidOutCell>[];
final sorted = [...appts]..sort((a, b) {
final c = a.startTime.compareTo(b.startTime);
if (c != 0) return c;
final p = _appointmentPriority(a).compareTo(_appointmentPriority(b));
if (p != 0) return p;
return b.endTime.compareTo(a.endTime);
});
@@ -1381,17 +1396,29 @@ List<_LaidOutCell> _assignLanes(List<Appointment> appts) {
final laneCount =
cluster.fold<int>(0, (m, e) => e.lane + 1 > m ? e.lane + 1 : m);
if (laneCount <= _kMaxVisibleCells) {
if (laneCount <= maxLanes) {
for (final entry in cluster) {
result.add(_LaidOutAppointment(entry.apt, entry.lane, laneCount));
}
} else {
// 3+ parallel appointments: keep the earliest, collapse the rest.
final byStart = [...cluster.map((e) => e.apt)]
..sort((a, b) => a.startTime.compareTo(b.startTime));
result.add(_LaidOutAppointment(byStart[0], 0, _kMaxVisibleCells));
// Too many parallel appointments: keep the highest-priority
// (maxLanes - 1) and collapse the rest into a single overflow cell in
// the trailing lane. Sorting by priority first means custom and
// cancelled lessons stay visible when the cluster has to be trimmed,
// matching the requested left-to-right order in the visible lanes.
final visibleCount = maxLanes - 1;
final byPriority = [...cluster.map((e) => e.apt)]
..sort((a, b) {
final p = _appointmentPriority(a).compareTo(_appointmentPriority(b));
if (p != 0) return p;
return a.startTime.compareTo(b.startTime);
});
final overflow = byStart.sublist(1);
for (var i = 0; i < visibleCount; i++) {
result.add(_LaidOutAppointment(byPriority[i], i, maxLanes));
}
final overflow = byPriority.sublist(visibleCount);
var earliest = overflow.first.startTime;
var latest = overflow.first.endTime;
for (final a in overflow.skip(1)) {
@@ -1399,7 +1426,7 @@ List<_LaidOutCell> _assignLanes(List<Appointment> appts) {
if (a.endTime.isAfter(latest)) latest = a.endTime;
}
result.add(_LaidOutOverflow(
overflow, _kMaxVisibleCells - 1, _kMaxVisibleCells, earliest, latest));
overflow, maxLanes - 1, maxLanes, earliest, latest));
}
}
return result;