diff --git a/lib/view/pages/timetable/data/timetable_appointment_factory.dart b/lib/view/pages/timetable/data/timetable_appointment_factory.dart index ba1d1b6..cdcb733 100644 --- a/lib/view/pages/timetable/data/timetable_appointment_factory.dart +++ b/lib/view/pages/timetable/data/timetable_appointment_factory.dart @@ -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 _mergeAdjacentLessons( List 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 = [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 = []; + 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; } } diff --git a/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart b/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart index 8bfdbd5..8a6ba56 100644 --- a/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart +++ b/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart @@ -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 appts) { +List<_LaidOutCell> _assignLanes(List 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 appts) { final laneCount = cluster.fold(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 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;