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:
@@ -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,
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user