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:
@@ -281,20 +281,15 @@ 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) {
|
||||
@@ -302,6 +297,7 @@ class _CustomEventEditDialogState extends State<CustomEventEditDialog> {
|
||||
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(
|
||||
// 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,
|
||||
);
|
||||
return _breakRegion(start, p.duration);
|
||||
})
|
||||
.where((region) => !isInHoliday(region.startTime));
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user