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,26 +281,22 @@ class _CustomEventEditDialogState extends State<CustomEventEditDialog> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
RRuleGenerator(
|
// The RRuleGenerator widget has zero outer padding while every
|
||||||
config: RRuleGeneratorConfig(
|
// surrounding ListTile uses the default 16px horizontal indent.
|
||||||
selectDayStyle: RRuleSelectDayStyle(
|
// Wrap it to match — keeps the rule editor visually aligned with
|
||||||
dayStyle: BoxDecoration(
|
// the date/time/color rows above instead of hugging the dialog
|
||||||
shape: BoxShape.circle,
|
// border.
|
||||||
color: Theme.of(context).colorScheme.secondary,
|
Padding(
|
||||||
),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||||
dayTextStyle: const TextStyle(color: Colors.black),
|
child: RRuleGenerator(
|
||||||
selectedDayStyle: BoxDecoration(
|
config: _rruleConfig(context),
|
||||||
shape: BoxShape.circle,
|
initialRRule: _rrule,
|
||||||
color: Theme.of(context).primaryColor,
|
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 parsed = RecurrenceRule.fromString(rule);
|
||||||
final anchorUtc = a.startTime.toUtc();
|
final anchorUtc = a.startTime.toUtc();
|
||||||
final duration = a.endTime.difference(a.startTime);
|
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)) {
|
for (final occUtc in parsed.getInstances(start: anchorUtc)) {
|
||||||
if (!occUtc.isBefore(weekEndUtc)) break;
|
if (!occUtc.isBefore(weekEndUtc)) break;
|
||||||
if (occUtc.isBefore(weekStartUtc)) continue;
|
if (occUtc.isBefore(weekStartUtc)) continue;
|
||||||
final occLocal = occUtc.toLocal();
|
final occLocal = occUtc.toLocal();
|
||||||
|
if (exceptionDayKeys.contains(
|
||||||
|
'${occLocal.year}-${occLocal.month}-${occLocal.day}',
|
||||||
|
)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
final idx = DateTime(
|
final idx = DateTime(
|
||||||
occLocal.year,
|
occLocal.year,
|
||||||
occLocal.month,
|
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 'arbitrary_appointment.dart';
|
||||||
import 'lesson_color.dart';
|
import 'lesson_color.dart';
|
||||||
import 'lesson_status.dart';
|
import 'lesson_status.dart';
|
||||||
|
import 'rrule_with_exceptions.dart';
|
||||||
import 'timetable_name_mode.dart';
|
import 'timetable_name_mode.dart';
|
||||||
import 'webuntis_time.dart';
|
import 'webuntis_time.dart';
|
||||||
|
|
||||||
@@ -81,6 +82,23 @@ class TimetableAppointmentFactory {
|
|||||||
|
|
||||||
Appointment _customEventToAppointment(CustomTimetableEvent event) {
|
Appointment _customEventToAppointment(CustomTimetableEvent event) {
|
||||||
final allDay = isCustomEventAllDay(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(
|
return Appointment(
|
||||||
id: CustomAppointment(event),
|
id: CustomAppointment(event),
|
||||||
startTime: event.startDate,
|
startTime: event.startDate,
|
||||||
@@ -101,7 +119,8 @@ class TimetableAppointmentFactory {
|
|||||||
? null
|
? null
|
||||||
: event.description.trim(),
|
: event.description.trim(),
|
||||||
subject: _collapseWhitespace(event.title) ?? event.title,
|
subject: _collapseWhitespace(event.title) ?? event.title,
|
||||||
recurrenceRule: event.rrule,
|
recurrenceRule: parsed.rule,
|
||||||
|
recurrenceExceptionDates: exceptionDates.isEmpty ? null : exceptionDates,
|
||||||
color: TimetableColors.getColorFromString(
|
color: TimetableColors.getColorFromString(
|
||||||
event.color ?? TimetableColors.defaultColor.name,
|
event.color ?? TimetableColors.defaultColor.name,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import '../../../../widget/centered_leading.dart';
|
|||||||
import '../../../../widget/debug/debug_tile.dart';
|
import '../../../../widget/debug/debug_tile.dart';
|
||||||
import '../../../../widget/details_bottom_sheet.dart';
|
import '../../../../widget/details_bottom_sheet.dart';
|
||||||
import '../custom_events/custom_event_edit_dialog.dart';
|
import '../custom_events/custom_event_edit_dialog.dart';
|
||||||
|
import '../data/rrule_with_exceptions.dart';
|
||||||
import 'delete_custom_event.dart';
|
import 'delete_custom_event.dart';
|
||||||
|
|
||||||
class CustomEventSheet {
|
class CustomEventSheet {
|
||||||
@@ -78,7 +79,13 @@ class CustomEventSheet {
|
|||||||
return const Text('Keine weiteren Vorkommnisse');
|
return const Text('Keine weiteren Vorkommnisse');
|
||||||
}
|
}
|
||||||
if (snapshot.data == null) return const Text('...');
|
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) {
|
if (!rrule.canFullyConvertToText) {
|
||||||
return const Text('Keine genauere Angabe möglich.');
|
return const Text('Keine genauere Angabe möglich.');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,11 +106,10 @@ class _TileContent extends StatelessWidget {
|
|||||||
|
|
||||||
if (isCustom) {
|
if (isCustom) {
|
||||||
if (description.isEmpty || bodyLineCapacity <= 0) {
|
if (description.isEmpty || bodyLineCapacity <= 0) {
|
||||||
return Column(
|
// Explicit height + FittedBox in the title keeps a too-tall
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
// intrinsic title (full font size > min-font-line-height) from
|
||||||
mainAxisSize: MainAxisSize.min,
|
// overflowing the tile by a couple of pixels.
|
||||||
children: [titleWidget],
|
return SizedBox(height: titleLineHeight, child: titleWidget);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
|||||||
@@ -22,36 +22,56 @@ class SpecialRegionsBuilder {
|
|||||||
});
|
});
|
||||||
|
|
||||||
List<TimeRegion> build() {
|
List<TimeRegion> build() {
|
||||||
final lastMonday = DateTime.now()
|
final rangeStart = DateTime.now()
|
||||||
.subtract(const Duration(days: 14))
|
.subtract(const Duration(days: 14))
|
||||||
.nextWeekday(DateTime.monday);
|
.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();
|
final holidayRegions = _buildHolidayRegions().toList();
|
||||||
bool isInHoliday(DateTime time) =>
|
final holidayDays = holidayRegions
|
||||||
holidayRegions.any((region) => region.startTime.isSameDay(time));
|
.map((r) => _dayKey(r.startTime))
|
||||||
|
.toSet();
|
||||||
|
|
||||||
final breakRegions = schedule.periods
|
// Materialise one TimeRegion per break per non-holiday day. Tried
|
||||||
.where((p) => p.isBreak)
|
// `recurrenceRule: FREQ=DAILY` with `recurrenceExceptionDates` first,
|
||||||
.map((p) {
|
// but Syncfusion's recurrence exception matching is unreliable in
|
||||||
final start = lastMonday.copyWith(
|
// practice — the break overlay kept showing on holiday days. Generating
|
||||||
hour: p.start.hour,
|
// explicit per-day regions and just skipping holidays is robust.
|
||||||
minute: p.start.minute,
|
final breakPeriods = schedule.periods.where((p) => p.isBreak).toList();
|
||||||
);
|
final breakRegions = <TimeRegion>[];
|
||||||
return _breakRegion(start, p.duration);
|
for (
|
||||||
})
|
var day = rangeStart;
|
||||||
.where((region) => !isInHoliday(region.startTime));
|
!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];
|
return [...holidayRegions, ...breakRegions];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static String _dayKey(DateTime d) => '${d.year}-${d.month}-${d.day}';
|
||||||
|
|
||||||
Iterable<TimeRegion> _buildHolidayRegions() => holidays.result.expand((
|
Iterable<TimeRegion> _buildHolidayRegions() => holidays.result.expand((
|
||||||
holiday,
|
holiday,
|
||||||
) {
|
) {
|
||||||
final startDay = WebuntisTime.parse(holiday.startDate, 0);
|
final startDay = WebuntisTime.parse(holiday.startDate, 0);
|
||||||
final dayCount = WebuntisTime.parse(
|
final endDay = WebuntisTime.parse(holiday.endDate, 0);
|
||||||
holiday.endDate,
|
// Webuntis treats endDate inclusively (last day of the break) — the
|
||||||
0,
|
// `+ 1` covers single-day public holidays (where startDate == endDate)
|
||||||
).difference(startDay).inDays;
|
// 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(
|
final days = List<DateTime>.generate(
|
||||||
dayCount,
|
dayCount,
|
||||||
(i) => startDay.add(Duration(days: i)),
|
(i) => startDay.add(Duration(days: i)),
|
||||||
@@ -66,7 +86,6 @@ class SpecialRegionsBuilder {
|
|||||||
endTime: day.copyWith(hour: gridEndHour, minute: gridEndMinute),
|
endTime: day.copyWith(hour: gridEndHour, minute: gridEndMinute),
|
||||||
text: '$kTimeRegionHolidayPrefix${holiday.name}',
|
text: '$kTimeRegionHolidayPrefix${holiday.name}',
|
||||||
color: disabledColor.withAlpha(50),
|
color: disabledColor.withAlpha(50),
|
||||||
iconData: Icons.holiday_village_outlined,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -74,7 +93,6 @@ class SpecialRegionsBuilder {
|
|||||||
TimeRegion _breakRegion(DateTime start, Duration duration) => TimeRegion(
|
TimeRegion _breakRegion(DateTime start, Duration duration) => TimeRegion(
|
||||||
startTime: start,
|
startTime: start,
|
||||||
endTime: start.add(duration),
|
endTime: start.add(duration),
|
||||||
recurrenceRule: 'FREQ=DAILY;INTERVAL=1',
|
|
||||||
text: kTimeRegionCenterIcon,
|
text: kTimeRegionCenterIcon,
|
||||||
color: colorScheme.primary.withAlpha(50),
|
color: colorScheme.primary.withAlpha(50),
|
||||||
iconData: Icons.restaurant,
|
iconData: Icons.restaurant,
|
||||||
|
|||||||
@@ -33,9 +33,10 @@ class TimeRegionTile extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 15),
|
const SizedBox(height: 15),
|
||||||
const Icon(Icons.cake),
|
Icon(region.iconData ?? Icons.event_outlined),
|
||||||
const Text('FREI'),
|
const SizedBox(height: 5),
|
||||||
const SizedBox(height: 10),
|
const Text('Schulfrei'),
|
||||||
|
const SizedBox(height: 15),
|
||||||
RotatedBox(
|
RotatedBox(
|
||||||
quarterTurns: 1,
|
quarterTurns: 1,
|
||||||
child: Text(
|
child: Text(
|
||||||
|
|||||||
Reference in New Issue
Block a user