fixed lesson merging mutation, improved overlap detection, and implemented priority-based lane assignment with tablet support
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user