implemented dynamic module settings and configurable bottom bar, added all-day event support to timetable, and overhauled marianum dates UI with month grouping and search

This commit is contained in:
2026-05-06 22:37:41 +02:00
parent 86d12884fc
commit 95ef29fb09
19 changed files with 1114 additions and 253 deletions
@@ -9,8 +9,8 @@ import 'package:time_range_picker/time_range_picker.dart';
import '../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart';
import '../../../../extensions/date_time.dart';
import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart';
import '../../../../widget/async_action_button.dart';
import '../../../../widget/focus_behaviour.dart';
import '../../../../widget/info_dialog.dart';
import 'custom_event_colors.dart';
class CustomEventEditDialog extends StatefulWidget {
@@ -34,15 +34,18 @@ class CustomEventEditDialog extends StatefulWidget {
}
class _CustomEventEditDialogState extends State<CustomEventEditDialog> {
// Visible window of the timetable / time picker (matches `_pickTimeRange`'s
// `disabledTime`). Pre-filled times from outside this window are clamped in.
// Selectable window for non-all-day events. Times outside this range are
// clamped in. For events outside school hours, use the all-day toggle.
static const TimeOfDay _windowStart = TimeOfDay(hour: 8, minute: 0);
static const TimeOfDay _windowEnd = TimeOfDay(hour: 16, minute: 30);
static const TimeOfDay _defaultStart = _windowStart;
static const TimeOfDay _defaultEnd = TimeOfDay(hour: 9, minute: 30);
static const int _minDurationMinutes = 15;
late DateTime _date = widget.existingEvent?.startDate ?? widget.initialStart ?? DateTime.now();
late TimeOfDay _startTime;
late TimeOfDay _endTime;
late bool _isAllDay;
late final TextEditingController _name = TextEditingController(
text: widget.existingEvent?.title ?? widget.initialTitle,
);
@@ -61,12 +64,22 @@ class _CustomEventEditDialogState extends State<CustomEventEditDialog> {
void initState() {
super.initState();
if (_isEditing) {
_startTime = widget.existingEvent!.startDate.toTimeOfDay();
_endTime = widget.existingEvent!.endDate.toTimeOfDay();
final s = widget.existingEvent!.startDate;
final e = widget.existingEvent!.endDate;
_isAllDay = isAllDayConvention(s, e);
if (_isAllDay) {
_startTime = _defaultStart;
_endTime = _defaultEnd;
} else {
final clamped = _clampToVisibleWindow(s.toTimeOfDay(), e.toTimeOfDay());
_startTime = clamped.$1;
_endTime = clamped.$2;
}
return;
}
final rawStart = widget.initialStart?.toTimeOfDay() ?? _windowStart;
final rawEnd = widget.initialEnd?.toTimeOfDay() ?? const TimeOfDay(hour: 9, minute: 30);
_isAllDay = false;
final rawStart = widget.initialStart?.toTimeOfDay() ?? _defaultStart;
final rawEnd = widget.initialEnd?.toTimeOfDay() ?? _defaultEnd;
final clamped = _clampToVisibleWindow(rawStart, rawEnd);
_startTime = clamped.$1;
_endTime = clamped.$2;
@@ -88,17 +101,38 @@ class _CustomEventEditDialogState extends State<CustomEventEditDialog> {
return (fromMin(start), fromMin(end));
}
bool _validate() => _name.text.isNotEmpty;
/// All-day convention shared with [TimetableAppointmentFactory]: a custom
/// event is treated as all-day when its start and end both land on midnight
/// of the same day. We piggyback on this so we don't need a backend schema
/// change.
static bool isAllDayConvention(DateTime start, DateTime end) =>
start.year == end.year &&
start.month == end.month &&
start.day == end.day &&
start.hour == 0 &&
start.minute == 0 &&
start.second == 0 &&
end.hour == 0 &&
end.minute == 0 &&
end.second == 0;
void _save() {
if (!_validate()) return;
Future<void> _save() async {
if (_name.text.trim().isEmpty) {
throw Exception('Bitte einen Terminnamen eingeben.');
}
// All-day convention: store start and end as midnight of the chosen day.
// The factory recognises this on read.
final midnight = DateTime(_date.year, _date.month, _date.day);
final startDate = _isAllDay ? midnight : _date.withTime(_startTime);
final endDate = _isAllDay ? midnight : _date.withTime(_endTime);
final edited = CustomTimetableEvent(
id: widget.existingEvent?.id ?? '',
title: _name.text,
description: _description.text,
startDate: _date.withTime(_startTime),
endDate: _date.withTime(_endTime),
startDate: startDate,
endDate: endDate,
color: _color.name,
rrule: _rrule,
createdAt: DateTime.now(),
@@ -106,17 +140,11 @@ class _CustomEventEditDialogState extends State<CustomEventEditDialog> {
);
final bloc = context.read<TimetableBloc>();
final future = _isEditing
? bloc.updateCustomEvent(widget.existingEvent!.id, edited)
: bloc.addCustomEvent(edited);
future.then((_) {
if (!mounted) return;
Navigator.of(context).pop();
}).catchError((Object error) {
if (!mounted) return;
InfoDialog.show(context, error.toString(), copyable: true, title: 'Fehler');
});
if (_isEditing) {
await bloc.updateCustomEvent(widget.existingEvent!.id, edited);
} else {
await bloc.addCustomEvent(edited);
}
}
Future<void> _pickDate() async {
@@ -138,8 +166,8 @@ class _CustomEventEditDialogState extends State<CustomEventEditDialog> {
start: _startTime,
end: _endTime,
disabledTime: TimeRange(
startTime: const TimeOfDay(hour: 16, minute: 30),
endTime: const TimeOfDay(hour: 8, minute: 0),
startTime: _windowEnd,
endTime: _windowStart,
),
disabledColor: Colors.grey,
paintingStyle: PaintingStyle.fill,
@@ -147,7 +175,7 @@ class _CustomEventEditDialogState extends State<CustomEventEditDialog> {
fromText: 'Beginnend',
toText: 'Endend',
strokeColor: Theme.of(context).colorScheme.secondary,
minDuration: const Duration(minutes: 15),
minDuration: Duration(minutes: _minDurationMinutes),
selectedColor: Theme.of(context).primaryColor,
ticks: 24,
);
@@ -191,12 +219,19 @@ class _CustomEventEditDialogState extends State<CustomEventEditDialog> {
subtitle: const Text('Datum'),
onTap: _pickDate,
),
ListTile(
leading: const Icon(Icons.access_time_outlined),
title: Text('${_startTime.format(context)} - ${_endTime.format(context)}'),
subtitle: const Text('Zeitraum'),
onTap: _pickTimeRange,
SwitchListTile(
secondary: const Icon(Icons.today_outlined),
title: const Text('Ganztägig'),
value: _isAllDay,
onChanged: (v) => setState(() => _isAllDay = v),
),
if (!_isAllDay)
ListTile(
leading: const Icon(Icons.access_time_outlined),
title: Text('${_startTime.format(context)} - ${_endTime.format(context)}'),
subtitle: const Text('Zeitraum'),
onTap: _pickTimeRange,
),
const Divider(),
ListTile(
leading: const Icon(Icons.color_lens_outlined),
@@ -246,8 +281,10 @@ class _CustomEventEditDialogState extends State<CustomEventEditDialog> {
),
),
actions: [
TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Abbrechen')),
TextButton(onPressed: _save, child: Text(_isEditing ? 'Speichern' : 'Erstellen')),
AsyncDialogAction(
confirmLabel: _isEditing ? 'Speichern' : 'Erstellen',
onConfirm: _save,
),
],
);
}
@@ -68,27 +68,51 @@ class TimetableAppointmentFactory {
}
}
Appointment _customEventToAppointment(CustomTimetableEvent event) => Appointment(
id: CustomAppointment(event),
startTime: event.startDate,
endTime: event.endDate,
location: _collapseWhitespace(event.description),
subject: _collapseWhitespace(event.title) ?? event.title,
recurrenceRule: event.rrule,
color: TimetableColors.getColorFromString(event.color ?? TimetableColors.defaultColor.name),
startTimeZone: '',
endTimeZone: '',
);
Appointment _customEventToAppointment(CustomTimetableEvent event) {
final allDay = isCustomEventAllDay(event);
return Appointment(
id: CustomAppointment(event),
startTime: event.startDate,
endTime: allDay
? DateTime(event.startDate.year, event.startDate.month, event.startDate.day, 23, 59)
: event.endDate,
isAllDay: allDay,
location: _collapseWhitespace(event.description),
subject: _collapseWhitespace(event.title) ?? event.title,
recurrenceRule: event.rrule,
color: TimetableColors.getColorFromString(event.color ?? TimetableColors.defaultColor.name),
startTimeZone: '',
endTimeZone: '',
);
}
/// All-day convention: a `CustomTimetableEvent` is treated as all-day when
/// its `startDate` and `endDate` both land on midnight of the same day.
/// Keeps the backend schema unchanged — the editor stores all-day events as
/// `start == end == midnight(date)`.
static bool isCustomEventAllDay(CustomTimetableEvent event) {
final s = event.startDate;
final e = event.endDate;
return s.year == e.year &&
s.month == e.month &&
s.day == e.day &&
s.hour == 0 &&
s.minute == 0 &&
s.second == 0 &&
e.hour == 0 &&
e.minute == 0 &&
e.second == 0;
}
String _subjectName(GetTimetableResponseObject lesson) {
final subject = subjects.result.firstWhereOrNull((s) => s.id == lesson.su.firstOrNull?.id);
if (subject == null) return 'Unbekannt';
if (subject == null) return 'Event';
final name = switch (settings.timetableNameMode) {
TimetableNameMode.name => subject.name,
TimetableNameMode.longName => subject.longName,
TimetableNameMode.alternateName => subject.alternateName,
};
return _collapseWhitespace(name) ?? 'Unbekannt';
return _collapseWhitespace(name) ?? 'Event';
}
String _locationLabel(GetTimetableResponseObject lesson) {
@@ -14,8 +14,9 @@ Completer<void> showDeleteCustomEventDialog(BuildContext context, CustomTimetabl
title: 'Termin löschen',
content: 'Der ${event.rrule.isEmpty ? "Termin" : "Serientermin"} wird unwiederruflich gelöscht.',
confirmButton: 'Löschen',
onConfirm: () {
bloc.removeCustomEvent(event.id).then(completer.complete).onError(completer.completeError);
onConfirmAsync: () async {
await bloc.removeCustomEvent(event.id);
completer.complete();
},
).asDialog(context);
return completer;
+14 -1
View File
@@ -78,14 +78,27 @@ class _TimetableState extends State<Timetable> {
return false;
}
bool _isOnInitialWeek(TimetableState state) {
final target = _initialDisplayDate();
final targetMonday = target.subtract(Duration(days: target.weekday - 1));
final mondayOnly = DateTime(targetMonday.year, targetMonday.month, targetMonday.day);
return state.startDate == mondayOnly;
}
@override
Widget build(BuildContext context) {
final bloc = context.read<TimetableBloc>();
final loadableState = context.watch<TimetableBloc>().state;
final innerState = loadableState.data;
final atToday = innerState != null && _isOnInitialWeek(innerState);
return Scaffold(
appBar: AppBar(
title: const Text('Stunden & Vertretungsplan'),
actions: [
IconButton(icon: const Icon(Icons.home_outlined), onPressed: _jumpToToday),
IconButton(
icon: const Icon(Icons.home_outlined),
onPressed: atToday ? null : _jumpToToday,
),
PopupMenuButton<_CalendarAction>(
icon: const Icon(Icons.edit_calendar_outlined),
onSelected: _onAction,
@@ -43,7 +43,7 @@ class CustomWorkWeekCalendar extends StatefulWidget {
}
class CustomWorkWeekCalendarState extends State<CustomWorkWeekCalendar> {
static const double _rulerWidth = 50;
static const double _rulerWidth = 36;
late PageController _pageController;
late int _currentWeekIndex;
@@ -128,6 +128,28 @@ class CustomWorkWeekCalendarState extends State<CustomWorkWeekCalendar> {
),
),
),
ClipRect(
child: AnimatedSize(
duration: const Duration(milliseconds: 320),
curve: Curves.easeOutCubic,
alignment: Alignment.topCenter,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 280),
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
transitionBuilder: (child, animation) =>
FadeTransition(opacity: animation, child: child),
child: _OutsideHoursStrip(
key: ValueKey(visibleWeekStart),
weekStart: visibleWeekStart,
appointments: widget.appointments,
rulerWidth: _rulerWidth,
onAppointmentTap: widget.onAppointmentTap,
isCrossedOut: widget.isCrossedOut,
),
),
),
),
Container(height: 0.5, color: theme.dividerColor.withAlpha(110)),
Expanded(
child: LayoutBuilder(
@@ -189,6 +211,271 @@ class CustomWorkWeekCalendarState extends State<CustomWorkWeekCalendar> {
}
}
class _OutsideHoursStrip extends StatelessWidget {
static const int _maxVisibleChips = 2;
static const double _chipHeight = 22;
static const double _chipSpacing = 3;
static const double _verticalPadding = 3;
final DateTime weekStart;
final List<Appointment> appointments;
final double rulerWidth;
final void Function(Appointment) onAppointmentTap;
final bool Function(Appointment) isCrossedOut;
const _OutsideHoursStrip({
super.key,
required this.weekStart,
required this.appointments,
required this.rulerWidth,
required this.onAppointmentTap,
required this.isCrossedOut,
});
@override
Widget build(BuildContext context) {
final outside = _partitionAppointmentsForWeek(appointments, weekStart).outside;
if (outside.every((day) => day.isEmpty)) return const SizedBox.shrink();
final theme = Theme.of(context);
final maxChipsPerDay = outside
.map((day) => day.length > _maxVisibleChips ? _maxVisibleChips : day.length)
.fold<int>(0, (m, c) => c > m ? c : m);
final stripHeight = _verticalPadding * 2 +
maxChipsPerDay * _chipHeight +
(maxChipsPerDay > 1 ? (maxChipsPerDay - 1) * _chipSpacing : 0);
return Container(
color: theme.colorScheme.surfaceContainerLowest,
padding: const EdgeInsets.symmetric(vertical: _verticalPadding),
child: SizedBox(
height: stripHeight - _verticalPadding * 2,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(width: rulerWidth),
for (var d = 0; d < 5; d++)
Expanded(
child: _OutsideDayColumn(
appointments: outside[d],
maxVisible: _maxVisibleChips,
chipHeight: _chipHeight,
chipSpacing: _chipSpacing,
onAppointmentTap: onAppointmentTap,
isCrossedOut: isCrossedOut,
),
),
],
),
),
);
}
}
class _OutsideDayColumn extends StatelessWidget {
final List<Appointment> appointments;
final int maxVisible;
final double chipHeight;
final double chipSpacing;
final void Function(Appointment) onAppointmentTap;
final bool Function(Appointment) isCrossedOut;
const _OutsideDayColumn({
required this.appointments,
required this.maxVisible,
required this.chipHeight,
required this.chipSpacing,
required this.onAppointmentTap,
required this.isCrossedOut,
});
void _showOverflow(BuildContext context, List<Appointment> hidden) {
showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (sheetCtx) => SafeArea(
child: ListView.separated(
shrinkWrap: true,
padding: const EdgeInsets.symmetric(vertical: 4),
itemCount: hidden.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (_, i) {
final apt = hidden[i];
return ListTile(
leading: Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: apt.color,
borderRadius: BorderRadius.circular(3),
),
),
title: Text(
apt.subject,
style: isCrossedOut(apt)
? const TextStyle(decoration: TextDecoration.lineThrough)
: null,
),
subtitle: Text(_subtitleFor(apt)),
onTap: () {
Navigator.of(sheetCtx).pop();
onAppointmentTap(apt);
},
);
},
),
),
);
}
static String _subtitleFor(Appointment a) {
if (_isAllDayLike(a)) return 'Ganztägig';
return '${_hm(a.startTime)}${_hm(a.endTime)}';
}
static String _hm(DateTime t) =>
'${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}';
@override
Widget build(BuildContext context) {
if (appointments.isEmpty) return const SizedBox.shrink();
final sorted = [...appointments]
..sort((a, b) {
final aLike = _isAllDayLike(a);
final bLike = _isAllDayLike(b);
if (aLike && !bLike) return -1;
if (!aLike && bLike) return 1;
return a.startTime.compareTo(b.startTime);
});
final visible = sorted.length <= maxVisible
? sorted
: sorted.take(maxVisible - 1).toList();
final overflow =
sorted.length <= maxVisible ? const <Appointment>[] : sorted.skip(maxVisible - 1).toList();
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (var i = 0; i < visible.length; i++) ...[
if (i > 0) SizedBox(height: chipSpacing),
SizedBox(
height: chipHeight,
child: _OutsideChip(
appointment: visible[i],
onTap: () => onAppointmentTap(visible[i]),
),
),
],
if (overflow.isNotEmpty) ...[
SizedBox(height: chipSpacing),
SizedBox(
height: chipHeight,
child: _OutsideOverflowChip(
count: overflow.length,
onTap: () => _showOverflow(context, overflow),
),
),
],
],
),
);
}
}
class _OutsideChip extends StatelessWidget {
final Appointment appointment;
final VoidCallback onTap;
const _OutsideChip({required this.appointment, required this.onTap});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final allDay = _isAllDayLike(appointment);
final timeLabel = allDay
? null
: '${appointment.startTime.hour.toString().padLeft(2, '0')}:${appointment.startTime.minute.toString().padLeft(2, '0')}';
return Material(
color: appointment.color.withAlpha(60),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(7)),
),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
child: Row(
mainAxisSize: MainAxisSize.max,
children: [
Expanded(
child: Text(
appointment.subject,
maxLines: 1,
overflow: TextOverflow.ellipsis,
softWrap: false,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
),
if (timeLabel != null) ...[
const SizedBox(width: 4),
Flexible(
child: Text(
timeLabel,
maxLines: 1,
overflow: TextOverflow.fade,
softWrap: false,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontSize: 10,
),
),
),
],
],
),
),
),
);
}
}
class _OutsideOverflowChip extends StatelessWidget {
final int count;
final VoidCallback onTap;
const _OutsideOverflowChip({required this.count, required this.onTap});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Material(
color: theme.colorScheme.secondaryContainer,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Center(
child: Text(
'+$count weitere',
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSecondaryContainer,
fontWeight: FontWeight.w600,
),
),
),
),
);
}
}
class _DayHeaderStrip extends StatelessWidget {
final DateTime weekStart;
final DateTime today;
@@ -301,7 +588,7 @@ class _WeekGrid extends StatelessWidget {
@override
Widget build(BuildContext context) {
final perDay = _expandAppointmentsForWeek(appointments, weekStart);
final partitioned = _partitionAppointmentsForWeek(appointments, weekStart);
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -316,7 +603,7 @@ class _WeekGrid extends StatelessWidget {
child: _DayColumn(
date: weekStart.add(Duration(days: d)),
schedule: schedule,
appointments: perDay[d],
appointments: partitioned.inside[d],
timeRegions: timeRegions,
layout: layout,
today: today,
@@ -389,9 +676,9 @@ class _PeriodLabel extends StatelessWidget {
}
final timeStyle = theme.textTheme.labelSmall?.copyWith(
color: secondaryTextColor,
color: secondaryTextColor.withAlpha(140),
height: 1.0,
fontSize: 10,
fontSize: 9,
);
const tightTextHeight = TextHeightBehavior(
applyHeightToFirstAscent: false,
@@ -422,7 +709,7 @@ class _PeriodLabel extends StatelessWidget {
),
),
Text(
'${period.name}.',
period.name,
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.onSurface,
fontWeight: FontWeight.w500,
@@ -728,25 +1015,34 @@ List<_BoundRegion> _expandRegionsForDay(List<TimeRegion> regions, DateTime day)
bool _isSameDay(DateTime a, DateTime b) =>
a.year == b.year && a.month == b.month && a.day == b.day;
/// Expands the given list of appointments across the visible 5-day work week,
/// resolving any RRULE-based recurrences into per-day synthetic instances.
/// Returns a list of length 5 (Monday..Friday); each entry holds the
/// appointments occurring on that day, with `startTime` and `endTime` shifted
/// to the actual occurrence date (preserving time-of-day and duration). The
/// original `id`, `subject`, `color`, `location` and `notes` are kept so taps
/// still resolve to the correct underlying event.
List<List<Appointment>> _expandAppointmentsForWeek(
List<Appointment> appointments, DateTime weekStart) {
final perDay = List<List<Appointment>>.generate(5, (_) => <Appointment>[]);
/// Expands the given list of appointments across the visible 5-day work week
/// (resolving RRULE recurrences) and splits each day's events into two
/// buckets: those that fit within the school-hours grid (`inside`) and those
/// that don't (`outside` — all-day events and events that start before
/// [kCalendarStartHour] or end after [kCalendarEndHour]). The outside bucket
/// is rendered as chips above the grid.
({List<List<Appointment>> inside, List<List<Appointment>> outside})
_partitionAppointmentsForWeek(
List<Appointment> appointments, DateTime weekStart) {
final inside = List<List<Appointment>>.generate(5, (_) => <Appointment>[]);
final outside = List<List<Appointment>>.generate(5, (_) => <Appointment>[]);
final weekEnd = weekStart.add(const Duration(days: 5));
final weekStartUtc = weekStart.toUtc();
final weekEndUtc = weekEnd.toUtc();
void place(int idx, Appointment a) {
if (_isOutsideSchoolHours(a)) {
outside[idx].add(a);
} else {
inside[idx].add(a);
}
}
for (final a in appointments) {
final rule = a.recurrenceRule;
if (rule == null || rule.isEmpty) {
final idx = a.startTime.difference(weekStart).inDays;
if (idx >= 0 && idx < 5) perDay[idx].add(a);
final idx = _dayIndex(a.startTime, weekStart);
if (idx >= 0 && idx < 5) place(idx, a);
continue;
}
try {
@@ -763,25 +1059,53 @@ List<List<Appointment>> _expandAppointmentsForWeek(
if (idx < 0 || idx >= 5) continue;
final newStart = DateTime(occLocal.year, occLocal.month, occLocal.day,
a.startTime.hour, a.startTime.minute);
perDay[idx].add(Appointment(
id: a.id,
startTime: newStart,
endTime: newStart.add(duration),
subject: a.subject,
color: a.color,
location: a.location,
notes: a.notes,
));
place(
idx,
Appointment(
id: a.id,
startTime: newStart,
endTime: newStart.add(duration),
subject: a.subject,
color: a.color,
location: a.location,
notes: a.notes,
isAllDay: a.isAllDay,
),
);
}
} catch (_) {
// Malformed RRULE → behave as non-recurring (anchor day only).
final idx = a.startTime.difference(weekStart).inDays;
if (idx >= 0 && idx < 5) perDay[idx].add(a);
final idx = _dayIndex(a.startTime, weekStart);
if (idx >= 0 && idx < 5) place(idx, a);
}
}
return perDay;
return (inside: inside, outside: outside);
}
int _dayIndex(DateTime t, DateTime weekStart) =>
DateTime(t.year, t.month, t.day).difference(weekStart).inDays;
/// True when the appointment doesn't fit into the school-hours grid:
/// all-day, fully before the grid start, fully after the grid end, engulfing
/// the grid entirely, or lasting 10+ hours (treated as a de-facto all-day
/// event the source system happens to represent with explicit times).
bool _isOutsideSchoolHours(Appointment a) {
if (_isAllDayLike(a)) return true;
final schoolStart = (kCalendarStartHour * 60).round();
final schoolEnd = (kCalendarEndHour * 60).round();
final startMin = a.startTime.hour * 60 + a.startTime.minute;
final endMin = a.endTime.hour * 60 + a.endTime.minute;
if (endMin <= schoolStart) return true;
if (startMin >= schoolEnd) return true;
if (startMin <= schoolStart && endMin >= schoolEnd) return true;
return false;
}
/// Either explicitly marked as all-day, or so long it's effectively a full
/// day from the user's perspective. We compare in minutes (not hours) because
/// `Duration.inHours` truncates: a 9h 30min event would otherwise count as 9.
bool _isAllDayLike(Appointment a) =>
a.isAllDay || a.endTime.difference(a.startTime).inMinutes >= 8 * 60;
/// Maps lesson periods to vertical screen positions. Every non-break period
/// gets the same fixed height (`lessonHeight`), every break gets `breakHeight`.
/// Short transition gaps (Wechselzeiten) between periods are not represented