383 lines
12 KiB
Dart
383 lines
12 KiB
Dart
import 'package:rrule/rrule.dart';
|
|
import 'package:syncfusion_flutter_calendar/calendar.dart';
|
|
|
|
import '../../../../extensions/date_time.dart';
|
|
import 'arbitrary_appointment.dart';
|
|
import 'calendar_layout.dart';
|
|
import 'lesson_period_schedule.dart';
|
|
|
|
/// Either explicitly marked as all-day, or so long it's effectively a full
|
|
/// day from the user's perspective. We compare in minutes (not hours) because
|
|
/// `Duration.inHours` truncates: a 9h 30min event would otherwise count as 9.
|
|
bool isAllDayLike(Appointment a) =>
|
|
a.isAllDay || a.endTime.difference(a.startTime).inMinutes >= 8 * 60;
|
|
|
|
/// True when the appointment doesn't fit into the school-hours grid:
|
|
/// all-day, fully before the grid start, fully after the grid end, engulfing
|
|
/// the grid entirely, or lasting 10+ hours (treated as a de-facto all-day
|
|
/// event the source system happens to represent with explicit times).
|
|
bool isOutsideSchoolHours(Appointment a) {
|
|
if (isAllDayLike(a)) return true;
|
|
final schoolStart = (kCalendarStartHour * 60).round();
|
|
final schoolEnd = (kCalendarEndHour * 60).round();
|
|
final startMin = a.startTime.hour * 60 + a.startTime.minute;
|
|
final endMin = a.endTime.hour * 60 + a.endTime.minute;
|
|
if (endMin <= schoolStart) return true;
|
|
if (startMin >= schoolEnd) return true;
|
|
if (startMin <= schoolStart && endMin >= schoolEnd) return true;
|
|
return false;
|
|
}
|
|
|
|
int dayIndex(DateTime t, DateTime weekStart) =>
|
|
DateTime(t.year, t.month, t.day).difference(weekStart).inDays;
|
|
|
|
class BoundRegion {
|
|
final TimeRegion region;
|
|
final DateTime start;
|
|
final DateTime end;
|
|
|
|
BoundRegion({required this.region, required this.start, required this.end});
|
|
}
|
|
|
|
List<BoundRegion> expandRegionsForDay(List<TimeRegion> regions, DateTime day) {
|
|
final result = <BoundRegion>[];
|
|
final dayStart = DateTime(day.year, day.month, day.day);
|
|
for (final region in regions) {
|
|
final isRecurringDaily =
|
|
region.recurrenceRule != null &&
|
|
region.recurrenceRule!.toUpperCase().contains('FREQ=DAILY');
|
|
if (isRecurringDaily) {
|
|
final start = dayStart.add(
|
|
Duration(
|
|
hours: region.startTime.hour,
|
|
minutes: region.startTime.minute,
|
|
),
|
|
);
|
|
final end = dayStart.add(
|
|
Duration(hours: region.endTime.hour, minutes: region.endTime.minute),
|
|
);
|
|
result.add(BoundRegion(region: region, start: start, end: end));
|
|
} else if (region.startTime.isSameDay(day)) {
|
|
result.add(
|
|
BoundRegion(
|
|
region: region,
|
|
start: region.startTime,
|
|
end: region.endTime,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/// Expands the given list of appointments across the visible 5-day work week
|
|
/// (resolving RRULE recurrences) and splits each day's events into two
|
|
/// buckets: those that fit within the school-hours grid (`inside`) and those
|
|
/// that don't (`outside` — all-day events and events that start before
|
|
/// [kCalendarStartHour] or end after [kCalendarEndHour]). The outside bucket
|
|
/// is rendered as chips above the grid.
|
|
({List<List<Appointment>> inside, List<List<Appointment>> outside})
|
|
partitionAppointmentsForWeek(
|
|
List<Appointment> appointments,
|
|
DateTime weekStart,
|
|
) {
|
|
final inside = List<List<Appointment>>.generate(5, (_) => <Appointment>[]);
|
|
final outside = List<List<Appointment>>.generate(5, (_) => <Appointment>[]);
|
|
final weekEnd = weekStart.add(const Duration(days: 5));
|
|
final weekStartUtc = weekStart.toUtc();
|
|
final weekEndUtc = weekEnd.toUtc();
|
|
|
|
void place(int idx, Appointment a) {
|
|
if (isOutsideSchoolHours(a)) {
|
|
outside[idx].add(a);
|
|
} else {
|
|
inside[idx].add(a);
|
|
}
|
|
}
|
|
|
|
for (final a in appointments) {
|
|
final rule = a.recurrenceRule;
|
|
if (rule == null || rule.isEmpty) {
|
|
final idx = dayIndex(a.startTime, weekStart);
|
|
if (idx >= 0 && idx < 5) place(idx, a);
|
|
continue;
|
|
}
|
|
try {
|
|
final parsed = RecurrenceRule.fromString(rule);
|
|
final anchorUtc = a.startTime.toUtc();
|
|
final duration = a.endTime.difference(a.startTime);
|
|
for (final occUtc in parsed.getInstances(start: anchorUtc)) {
|
|
if (!occUtc.isBefore(weekEndUtc)) break;
|
|
if (occUtc.isBefore(weekStartUtc)) continue;
|
|
final occLocal = occUtc.toLocal();
|
|
final idx = DateTime(
|
|
occLocal.year,
|
|
occLocal.month,
|
|
occLocal.day,
|
|
).difference(weekStart).inDays;
|
|
if (idx < 0 || idx >= 5) continue;
|
|
final newStart = DateTime(
|
|
occLocal.year,
|
|
occLocal.month,
|
|
occLocal.day,
|
|
a.startTime.hour,
|
|
a.startTime.minute,
|
|
);
|
|
place(
|
|
idx,
|
|
Appointment(
|
|
id: a.id,
|
|
startTime: newStart,
|
|
endTime: newStart.add(duration),
|
|
subject: a.subject,
|
|
color: a.color,
|
|
location: a.location,
|
|
notes: a.notes,
|
|
isAllDay: a.isAllDay,
|
|
),
|
|
);
|
|
}
|
|
} catch (_) {
|
|
final idx = dayIndex(a.startTime, weekStart);
|
|
if (idx >= 0 && idx < 5) place(idx, a);
|
|
}
|
|
}
|
|
return (inside: inside, outside: outside);
|
|
}
|
|
|
|
/// Maps lesson periods to vertical screen positions. Every non-break period
|
|
/// gets the same fixed height (`lessonHeight`), every break gets `breakHeight`.
|
|
/// Short transition gaps (Wechselzeiten) between periods are not represented
|
|
/// at all — periods are rendered back-to-back, so a 5-minute gap simply
|
|
/// disappears visually.
|
|
class PeriodLayout {
|
|
final List<LessonPeriod> periods;
|
|
final double lessonHeight;
|
|
final double breakHeight;
|
|
|
|
const PeriodLayout({
|
|
required this.periods,
|
|
required this.lessonHeight,
|
|
required this.breakHeight,
|
|
});
|
|
|
|
double _h(LessonPeriod p) => p.isBreak ? breakHeight : lessonHeight;
|
|
|
|
double get totalHeight => periods.fold<double>(0, (sum, p) => sum + _h(p));
|
|
|
|
double topOf(LessonPeriod period) {
|
|
var y = 0.0;
|
|
for (final p in periods) {
|
|
if (identical(p, period)) return y;
|
|
y += _h(p);
|
|
}
|
|
return y;
|
|
}
|
|
|
|
double heightOf(LessonPeriod period) => _h(period);
|
|
|
|
/// Vertical offset for a given time of day. Times inside a period are mapped
|
|
/// proportionally; times that fall into a transition gap are clipped to the
|
|
/// end of the preceding period. Times before the first / after the last
|
|
/// period clip to 0 / [totalHeight].
|
|
double yOfDateTime(DateTime t) {
|
|
final tMin = t.hour * 60 + t.minute + t.second / 60.0;
|
|
var y = 0.0;
|
|
for (final p in periods) {
|
|
final pStart = p.start.hour * 60 + p.start.minute;
|
|
final pEnd = p.end.hour * 60 + p.end.minute;
|
|
final h = _h(p);
|
|
if (tMin < pStart) return y;
|
|
if (tMin <= pEnd) {
|
|
final span = pEnd - pStart;
|
|
final ratio = span > 0 ? (tMin - pStart) / span : 0.0;
|
|
return y + ratio * h;
|
|
}
|
|
y += h;
|
|
}
|
|
return y;
|
|
}
|
|
|
|
/// Period at a given y-offset. If y falls into a break, returns the next
|
|
/// non-break period. Returns null when y is past the last period.
|
|
LessonPeriod? periodAtY(double y) {
|
|
var cursor = 0.0;
|
|
for (var i = 0; i < periods.length; i++) {
|
|
final p = periods[i];
|
|
final h = _h(p);
|
|
if (y >= cursor && y < cursor + h) {
|
|
if (p.isBreak) {
|
|
for (var j = i + 1; j < periods.length; j++) {
|
|
if (!periods[j].isBreak) return periods[j];
|
|
}
|
|
return null;
|
|
}
|
|
return p;
|
|
}
|
|
cursor += h;
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// One cell rendered in the day column — either a regular appointment or an
|
|
/// overflow placeholder representing several hidden appointments.
|
|
sealed class LaidOutCell {
|
|
int get lane;
|
|
int get laneCount;
|
|
DateTime get startTime;
|
|
DateTime get endTime;
|
|
}
|
|
|
|
class LaidOutAppointment extends LaidOutCell {
|
|
final Appointment appointment;
|
|
@override
|
|
final int lane;
|
|
@override
|
|
final int laneCount;
|
|
LaidOutAppointment(this.appointment, this.lane, this.laneCount);
|
|
|
|
@override
|
|
DateTime get startTime => appointment.startTime;
|
|
@override
|
|
DateTime get endTime => appointment.endTime;
|
|
}
|
|
|
|
class LaidOutOverflow extends LaidOutCell {
|
|
final List<Appointment> appointments;
|
|
@override
|
|
final int lane;
|
|
@override
|
|
final int laneCount;
|
|
@override
|
|
final DateTime startTime;
|
|
@override
|
|
final DateTime endTime;
|
|
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 [maxLanes] into `(maxLanes - 1)` visible appointments
|
|
/// + one trailing overflow cell.
|
|
///
|
|
/// Greedy sweep:
|
|
/// 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, {
|
|
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);
|
|
});
|
|
|
|
// Phase 1: greedy lane assignment, grouped by cluster.
|
|
final clusters = <List<({Appointment apt, int lane})>>[];
|
|
var current = <({Appointment apt, int lane})>[];
|
|
var laneEnds = <DateTime>[];
|
|
|
|
for (final apt in sorted) {
|
|
final allFree =
|
|
laneEnds.isNotEmpty &&
|
|
laneEnds.every((end) => !end.isAfter(apt.startTime));
|
|
if (allFree) {
|
|
clusters.add(current);
|
|
current = <({Appointment apt, int lane})>[];
|
|
laneEnds = <DateTime>[];
|
|
}
|
|
|
|
var laneIdx = -1;
|
|
for (var i = 0; i < laneEnds.length; i++) {
|
|
if (!laneEnds[i].isAfter(apt.startTime)) {
|
|
laneIdx = i;
|
|
break;
|
|
}
|
|
}
|
|
if (laneIdx == -1) {
|
|
laneIdx = laneEnds.length;
|
|
laneEnds.add(apt.endTime);
|
|
} else {
|
|
laneEnds[laneIdx] = apt.endTime;
|
|
}
|
|
|
|
current.add((apt: apt, lane: laneIdx));
|
|
}
|
|
if (current.isNotEmpty) clusters.add(current);
|
|
|
|
// Phase 2: emit cells per cluster, collapsing if too wide.
|
|
final result = <LaidOutCell>[];
|
|
for (final cluster in clusters) {
|
|
final laneCount = cluster.fold<int>(
|
|
0,
|
|
(m, e) => e.lane + 1 > m ? e.lane + 1 : m,
|
|
);
|
|
|
|
if (laneCount <= maxLanes) {
|
|
for (final entry in cluster) {
|
|
result.add(LaidOutAppointment(entry.apt, entry.lane, laneCount));
|
|
}
|
|
} else {
|
|
// 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);
|
|
});
|
|
|
|
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)) {
|
|
if (a.startTime.isBefore(earliest)) earliest = a.startTime;
|
|
if (a.endTime.isAfter(latest)) latest = a.endTime;
|
|
}
|
|
result.add(
|
|
LaidOutOverflow(overflow, maxLanes - 1, maxLanes, earliest, latest),
|
|
);
|
|
}
|
|
}
|
|
return result;
|
|
}
|