implemented recurrence exception (EXDATE) support for custom events, refactored timetable break and holiday generation logic, and refined RRule editor UI/theming and tile layouts

This commit is contained in:
2026-05-14 12:58:29 +02:00
parent 194d8d1857
commit 2cb8321d07
8 changed files with 221 additions and 48 deletions
@@ -281,26 +281,22 @@ class _CustomEventEditDialogState extends State<CustomEventEditDialog> {
),
),
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<CustomEventEditDialog> {
),
],
);
/// 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),
);
}
}
@@ -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 <DateTime>[])
.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,
@@ -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<DateTime> 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 <DateTime>[]
: match
.group(1)!
.split(',')
.map(_parseExdate)
.whereType<DateTime>()
.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!);
}
@@ -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,
),
@@ -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.');
}
@@ -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,
@@ -22,36 +22,56 @@ class SpecialRegionsBuilder {
});
List<TimeRegion> 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 = <TimeRegion>[];
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<TimeRegion> _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<DateTime>.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,
@@ -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(