import 'package:flutter/material.dart'; import 'package:syncfusion_flutter_calendar/calendar.dart'; import '../../../../api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays_response.dart'; import '../../../../extensions/date_time.dart'; import '../data/calendar_layout.dart'; import '../data/lesson_period_schedule.dart'; import 'time_region_tile.dart'; class SpecialRegionsBuilder { final TimetableGetHolidaysResponse holidays; final LessonPeriodSchedule schedule; final ColorScheme colorScheme; final Color disabledColor; SpecialRegionsBuilder({ required this.holidays, required this.schedule, required this.colorScheme, required this.disabledColor, }); List build() { final rangeStart = DateTime.now() .subtractDays(14) .nextWeekday(DateTime.monday); // Far enough out to cover comfortable scrolling without rebuilding; // Syncfusion only paints regions that intersect the visible window so // the extra entries don't hurt rendering cost. final rangeEnd = DateTime.now().addDays(180); final holidayRegions = _buildHolidayRegions().toList(); final holidayDays = holidayRegions .map((r) => _dayKey(r.startTime)) .toSet(); // Materialise one TimeRegion per break per non-holiday day. Tried // `recurrenceRule: FREQ=DAILY` with `recurrenceExceptionDates` first, // but Syncfusion's recurrence exception matching is unreliable in // practice — the break overlay kept showing on holiday days. Generating // explicit per-day regions and just skipping holidays is robust. final breakPeriods = schedule.periods.where((p) => p.isBreak).toList(); final breakRegions = []; for (var day = rangeStart; !day.isAfter(rangeEnd); day = day.addDays(1)) { if (holidayDays.contains(_dayKey(day))) continue; for (final p in breakPeriods) { final start = day.copyWith( hour: p.start.hour, minute: p.start.minute, ); breakRegions.add(_breakRegion(start, p.duration)); } } return [...holidayRegions, ...breakRegions]; } static String _dayKey(DateTime d) => '${d.year}-${d.month}-${d.day}'; Iterable _buildHolidayRegions() { // Multiple holiday entries can cover the same day (e.g. a public holiday // falling inside a vacation period). Collapse them per-day so we emit // exactly one TimeRegion per day and the overlapping labels don't render // on top of each other. final byDay = {}; for (final holiday in holidays.result) { final startDay = DateTime( holiday.startDate.year, holiday.startDate.month, holiday.startDate.day, ); final endDay = DateTime( holiday.endDate.year, holiday.endDate.month, holiday.endDate.day, ); // Webuntis treats endDate inclusively (last day of the break) — the // `+ 1` covers single-day public holidays (where startDate == endDate) // and the final day of a multi-day vacation, both of which would // otherwise be skipped. final dayCount = endDay.difference(startDay).inDays + 1; for (var i = 0; i < dayCount; i++) { final day = startDay.addDays(i); final key = _dayKey(day); byDay.putIfAbsent(key, () => _HolidayDay(day, [])).names.add( holiday.shortName, ); } } final gridStartHour = kCalendarStartHour.floor(); final gridStartMinute = ((kCalendarStartHour - gridStartHour) * 60).round(); final gridEndHour = kCalendarEndHour.floor(); final gridEndMinute = ((kCalendarEndHour - gridEndHour) * 60).round(); return byDay.values.map( (entry) => TimeRegion( startTime: entry.day.copyWith( hour: gridStartHour, minute: gridStartMinute, ), endTime: entry.day.copyWith( hour: gridEndHour, minute: gridEndMinute, ), text: '$kTimeRegionHolidayPrefix${entry.names.join(" / ")}', color: disabledColor.withAlpha(50), ), ); } TimeRegion _breakRegion(DateTime start, Duration duration) => TimeRegion( startTime: start, endTime: start.add(duration), text: kTimeRegionCenterIcon, color: colorScheme.primary.withAlpha(50), iconData: Icons.restaurant, ); } class _HolidayDay { final DateTime day; final List names; _HolidayDay(this.day, this.names); }