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 expandRegionsForDay(List regions, DateTime day) { final result = []; 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> inside, List> outside}) partitionAppointmentsForWeek( List appointments, DateTime weekStart) { final inside = List>.generate(5, (_) => []); final outside = List>.generate(5, (_) => []); 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 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(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 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 assignLanes(List appts, {required int maxLanes}) { assert(maxLanes >= 2, 'maxLanes must reserve at least one slot for overflow'); if (appts.isEmpty) return const []; 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 = >[]; var current = <({Appointment apt, int lane})>[]; var laneEnds = []; 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 = []; } 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 = []; for (final cluster in clusters) { final laneCount = cluster.fold(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; }