/// 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!); }