import 'package:flutter/material.dart'; import 'package:syncfusion_flutter_calendar/calendar.dart'; import '../../../../api/webuntis/queries/get_holidays/get_holidays_response.dart'; import '../../../../extensions/date_time.dart'; import '../data/calendar_layout.dart'; import '../data/lesson_period_schedule.dart'; import '../data/webuntis_time.dart'; import 'time_region_tile.dart'; class SpecialRegionsBuilder { final GetHolidaysResponse 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() .subtract(const Duration(days: 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().add(const Duration(days: 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.add(const Duration(days: 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 Webuntis 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 = WebuntisTime.parse(holiday.startDate, 0); final endDay = WebuntisTime.parse(holiday.endDate, 0); // 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.add(Duration(days: i)); final key = _dayKey(day); byDay.putIfAbsent(key, () => _HolidayDay(day, [])).names.add( holiday.name, ); } } 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); }