diff --git a/lib/view/pages/timetable/custom_events/custom_event_edit_dialog.dart b/lib/view/pages/timetable/custom_events/custom_event_edit_dialog.dart index cbb3754..8503e60 100644 --- a/lib/view/pages/timetable/custom_events/custom_event_edit_dialog.dart +++ b/lib/view/pages/timetable/custom_events/custom_event_edit_dialog.dart @@ -281,26 +281,22 @@ class _CustomEventEditDialogState extends State { ), ), const Divider(), - RRuleGenerator( - config: RRuleGeneratorConfig( - selectDayStyle: RRuleSelectDayStyle( - dayStyle: BoxDecoration( - shape: BoxShape.circle, - color: Theme.of(context).colorScheme.secondary, - ), - dayTextStyle: const TextStyle(color: Colors.black), - selectedDayStyle: BoxDecoration( - shape: BoxShape.circle, - color: Theme.of(context).primaryColor, - ), - ), + // The RRuleGenerator widget has zero outer padding while every + // surrounding ListTile uses the default 16px horizontal indent. + // Wrap it to match — keeps the rule editor visually aligned with + // the date/time/color rows above instead of hugging the dialog + // border. + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: RRuleGenerator( + config: _rruleConfig(context), + initialRRule: _rrule, + locale: RRuleLocale.de_DE, + onChange: (newValue) { + log('Rule: $newValue'); + setState(() => _rrule = newValue); + }, ), - initialRRule: _rrule, - locale: RRuleLocale.de_DE, - onChange: (newValue) { - log('Rule: $newValue'); - setState(() => _rrule = newValue); - }, ), ], ), @@ -312,4 +308,69 @@ class _CustomEventEditDialogState extends State { ), ], ); + + /// Maps the rrule_generator widget onto Marianum's Material theme so its + /// pickers blend in with the rest of the form (the package's defaults + /// have an out-of-place red switch outline and bold ALL-CAPS-feeling + /// headers). Layout itself stays as the package provides — fully custom + /// styling beyond what `RRuleGeneratorConfig` exposes isn't possible + /// without forking it. + RRuleGeneratorConfig _rruleConfig(BuildContext context) { + final theme = Theme.of(context); + final cs = theme.colorScheme; + return RRuleGeneratorConfig( + headerStyle: RRuleHeaderStyle( + textStyle: + theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + color: cs.onSurface, + ) ?? + const TextStyle(), + ), + labelStyle: + theme.textTheme.bodyMedium?.copyWith(color: cs.onSurfaceVariant) ?? + const TextStyle(), + inputTextStyle: RRuleInputTextStyle( + inputTextDecoration: const InputDecoration( + isDense: true, + border: OutlineInputBorder(), + ), + inputTextStyle: + theme.textTheme.bodyMedium ?? const TextStyle(), + ), + dropdownStyle: RRuleDropdownStyle( + dropdownMenuTextStyle: theme.textTheme.bodyMedium ?? const TextStyle(), + dropdownMenuItemTextStyle: + theme.textTheme.bodyMedium ?? const TextStyle(), + ), + switchStyle: RRuleSwitchStyle( + thumbColor: cs.onPrimary, + activeTrackColor: cs.primary, + inactiveTrackColor: cs.surfaceContainerHighest, + trackOutlineWidth: 0, + trackOutlineColor: Colors.transparent, + switchTextStyle: + theme.textTheme.bodyMedium?.copyWith( + color: cs.onSurface, + ) ?? + const TextStyle(), + ), + selectDayStyle: RRuleSelectDayStyle( + dayStyle: BoxDecoration( + shape: BoxShape.circle, + color: cs.surfaceContainerHighest, + ), + dayTextStyle: TextStyle(color: cs.onSurface), + selectedDayStyle: BoxDecoration( + shape: BoxShape.circle, + color: cs.primary, + ), + selectedDayTextStyle: TextStyle(color: cs.onPrimary), + ), + datePickerStyle: RRuleDatePickerStyle( + datePickerTextStyle: theme.textTheme.bodyMedium ?? const TextStyle(), + ), + divider: const Divider(height: 1), + ); + } } diff --git a/lib/view/pages/timetable/data/calendar_logic.dart b/lib/view/pages/timetable/data/calendar_logic.dart index aeead8b..8560876 100644 --- a/lib/view/pages/timetable/data/calendar_logic.dart +++ b/lib/view/pages/timetable/data/calendar_logic.dart @@ -106,10 +106,21 @@ partitionAppointmentsForWeek( final parsed = RecurrenceRule.fromString(rule); final anchorUtc = a.startTime.toUtc(); final duration = a.endTime.difference(a.startTime); + // Day-keyed set of exception dates so occurrences scheduled for one + // of them get skipped. Syncfusion's own recurrenceExceptionDates + // handling never runs because we expand the rule manually here. + final exceptionDayKeys = (a.recurrenceExceptionDates ?? const []) + .map((d) => '${d.year}-${d.month}-${d.day}') + .toSet(); for (final occUtc in parsed.getInstances(start: anchorUtc)) { if (!occUtc.isBefore(weekEndUtc)) break; if (occUtc.isBefore(weekStartUtc)) continue; final occLocal = occUtc.toLocal(); + if (exceptionDayKeys.contains( + '${occLocal.year}-${occLocal.month}-${occLocal.day}', + )) { + continue; + } final idx = DateTime( occLocal.year, occLocal.month, diff --git a/lib/view/pages/timetable/data/rrule_with_exceptions.dart b/lib/view/pages/timetable/data/rrule_with_exceptions.dart new file mode 100644 index 0000000..1a47415 --- /dev/null +++ b/lib/view/pages/timetable/data/rrule_with_exceptions.dart @@ -0,0 +1,57 @@ +/// Parses recurrence-rule strings produced by the `rrule_generator` widget +/// into a clean RRULE plus a separate list of exception dates. +/// +/// `rrule_generator` appends excluded dates as `;EXDATE=20260514T000000,...` +/// to the rule string, which is **not** valid iCalendar — `EXDATE` is its +/// own property, not an RRULE parameter. Anything reading the raw string +/// with a strict parser (Syncfusion, package:rrule) silently drops the +/// EXDATE bit, so exclusions never apply. Split the two here. +class ParsedRRule { + final String rule; + final List exceptions; + const ParsedRRule({required this.rule, required this.exceptions}); + + static const empty = ParsedRRule(rule: '', exceptions: []); +} + +ParsedRRule parseRRuleWithExceptions(String input) { + if (input.isEmpty) return ParsedRRule.empty; + final match = RegExp(r';EXDATE=([^;]+)$').firstMatch(input); + final exceptions = match == null + ? const [] + : match + .group(1)! + .split(',') + .map(_parseExdate) + .whereType() + .toList(); + // Keep the rule string as-is (still has its `RRULE:` content-line prefix + // when present). Both Syncfusion's `Appointment.recurrenceRule` and + // `package:rrule`'s `RecurrenceRule.fromString` parse it correctly with + // the prefix; stripping it was a regression that confused Syncfusion's + // weekly BYDAY decoder on multi-day rules. + final rule = match == null ? input : input.substring(0, match.start); + return ParsedRRule(rule: rule, exceptions: exceptions); +} + +/// Parses one `yyyyMMddTHHmmss` chunk into a local-time [DateTime]. +DateTime? _parseExdate(String s) { + if (s.length < 15 || s[8] != 'T') return null; + final year = int.tryParse(s.substring(0, 4)); + final month = int.tryParse(s.substring(4, 6)); + final day = int.tryParse(s.substring(6, 8)); + final hour = int.tryParse(s.substring(9, 11)); + final minute = int.tryParse(s.substring(11, 13)); + final second = int.tryParse(s.substring(13, 15)); + if ([ + year, + month, + day, + hour, + minute, + second, + ].any((e) => e == null)) { + return null; + } + return DateTime(year!, month!, day!, hour!, minute!, second!); +} diff --git a/lib/view/pages/timetable/data/timetable_appointment_factory.dart b/lib/view/pages/timetable/data/timetable_appointment_factory.dart index cbce1a1..9847415 100644 --- a/lib/view/pages/timetable/data/timetable_appointment_factory.dart +++ b/lib/view/pages/timetable/data/timetable_appointment_factory.dart @@ -10,6 +10,7 @@ import '../custom_events/custom_event_colors.dart'; import 'arbitrary_appointment.dart'; import 'lesson_color.dart'; import 'lesson_status.dart'; +import 'rrule_with_exceptions.dart'; import 'timetable_name_mode.dart'; import 'webuntis_time.dart'; @@ -81,6 +82,23 @@ class TimetableAppointmentFactory { Appointment _customEventToAppointment(CustomTimetableEvent event) { final allDay = isCustomEventAllDay(event); + // rrule_generator stores excluded dates as a non-standard ";EXDATE=..." + // suffix on the rule string. Strip it and feed the dates separately — + // Syncfusion matches recurrence exceptions through this property, not + // through anything inside `recurrenceRule`. + final parsed = parseRRuleWithExceptions(event.rrule); + final exceptionDates = parsed.exceptions + .map( + (d) => DateTime( + d.year, + d.month, + d.day, + event.startDate.hour, + event.startDate.minute, + event.startDate.second, + ), + ) + .toList(); return Appointment( id: CustomAppointment(event), startTime: event.startDate, @@ -101,7 +119,8 @@ class TimetableAppointmentFactory { ? null : event.description.trim(), subject: _collapseWhitespace(event.title) ?? event.title, - recurrenceRule: event.rrule, + recurrenceRule: parsed.rule, + recurrenceExceptionDates: exceptionDates.isEmpty ? null : exceptionDates, color: TimetableColors.getColorFromString( event.color ?? TimetableColors.defaultColor.name, ), diff --git a/lib/view/pages/timetable/details/custom_event_sheet.dart b/lib/view/pages/timetable/details/custom_event_sheet.dart index dc7b6d5..a9de7ab 100644 --- a/lib/view/pages/timetable/details/custom_event_sheet.dart +++ b/lib/view/pages/timetable/details/custom_event_sheet.dart @@ -7,6 +7,7 @@ import '../../../../widget/centered_leading.dart'; import '../../../../widget/debug/debug_tile.dart'; import '../../../../widget/details_bottom_sheet.dart'; import '../custom_events/custom_event_edit_dialog.dart'; +import '../data/rrule_with_exceptions.dart'; import 'delete_custom_event.dart'; class CustomEventSheet { @@ -78,7 +79,13 @@ class CustomEventSheet { return const Text('Keine weiteren Vorkommnisse'); } if (snapshot.data == null) return const Text('...'); - final rrule = RecurrenceRule.fromString(event.rrule); + // Strip the rrule_generator-specific `;EXDATE=…` suffix + // first — `package:rrule` is strict and throws on it. + final parsed = parseRRuleWithExceptions(event.rrule); + if (parsed.rule.isEmpty) { + return const Text('Keine weiteren Vorkommnisse'); + } + final rrule = RecurrenceRule.fromString(parsed.rule); if (!rrule.canFullyConvertToText) { return const Text('Keine genauere Angabe möglich.'); } diff --git a/lib/view/pages/timetable/widgets/appointment_tile.dart b/lib/view/pages/timetable/widgets/appointment_tile.dart index 2931630..0d8c1cf 100644 --- a/lib/view/pages/timetable/widgets/appointment_tile.dart +++ b/lib/view/pages/timetable/widgets/appointment_tile.dart @@ -106,11 +106,10 @@ class _TileContent extends StatelessWidget { if (isCustom) { if (description.isEmpty || bodyLineCapacity <= 0) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, - children: [titleWidget], - ); + // Explicit height + FittedBox in the title keeps a too-tall + // intrinsic title (full font size > min-font-line-height) from + // overflowing the tile by a couple of pixels. + return SizedBox(height: titleLineHeight, child: titleWidget); } return Column( crossAxisAlignment: CrossAxisAlignment.stretch, diff --git a/lib/view/pages/timetable/widgets/special_regions_builder.dart b/lib/view/pages/timetable/widgets/special_regions_builder.dart index 02a06be..6fa073c 100644 --- a/lib/view/pages/timetable/widgets/special_regions_builder.dart +++ b/lib/view/pages/timetable/widgets/special_regions_builder.dart @@ -22,36 +22,56 @@ class SpecialRegionsBuilder { }); List build() { - final lastMonday = DateTime.now() + 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(); - bool isInHoliday(DateTime time) => - holidayRegions.any((region) => region.startTime.isSameDay(time)); + final holidayDays = holidayRegions + .map((r) => _dayKey(r.startTime)) + .toSet(); - final breakRegions = schedule.periods - .where((p) => p.isBreak) - .map((p) { - final start = lastMonday.copyWith( - hour: p.start.hour, - minute: p.start.minute, - ); - return _breakRegion(start, p.duration); - }) - .where((region) => !isInHoliday(region.startTime)); + // 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() => holidays.result.expand(( holiday, ) { final startDay = WebuntisTime.parse(holiday.startDate, 0); - final dayCount = WebuntisTime.parse( - holiday.endDate, - 0, - ).difference(startDay).inDays; + 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; final days = List.generate( dayCount, (i) => startDay.add(Duration(days: i)), @@ -66,7 +86,6 @@ class SpecialRegionsBuilder { endTime: day.copyWith(hour: gridEndHour, minute: gridEndMinute), text: '$kTimeRegionHolidayPrefix${holiday.name}', color: disabledColor.withAlpha(50), - iconData: Icons.holiday_village_outlined, ), ); }); @@ -74,7 +93,6 @@ class SpecialRegionsBuilder { TimeRegion _breakRegion(DateTime start, Duration duration) => TimeRegion( startTime: start, endTime: start.add(duration), - recurrenceRule: 'FREQ=DAILY;INTERVAL=1', text: kTimeRegionCenterIcon, color: colorScheme.primary.withAlpha(50), iconData: Icons.restaurant, diff --git a/lib/view/pages/timetable/widgets/time_region_tile.dart b/lib/view/pages/timetable/widgets/time_region_tile.dart index 292e170..fc7dced 100644 --- a/lib/view/pages/timetable/widgets/time_region_tile.dart +++ b/lib/view/pages/timetable/widgets/time_region_tile.dart @@ -33,9 +33,10 @@ class TimeRegionTile extends StatelessWidget { child: Column( children: [ const SizedBox(height: 15), - const Icon(Icons.cake), - const Text('FREI'), - const SizedBox(height: 10), + Icon(region.iconData ?? Icons.event_outlined), + const SizedBox(height: 5), + const Text('Schulfrei'), + const SizedBox(height: 15), RotatedBox( quarterTurns: 1, child: Text(