simplified code across the app and consolidated duplicated helpers

This commit is contained in:
2026-09-27 17:29:54 +02:00
parent 3e856d0b1c
commit ee1240f71a
155 changed files with 1772 additions and 2546 deletions
@@ -13,6 +13,7 @@ import '../../../../widget/async_action_button.dart';
import '../../../../widget/demo_restricted.dart';
import '../../../../widget/focus_behaviour.dart';
import '../data/subject_color_palette.dart';
import '../data/timetable_appointment_factory.dart';
import '../subject_colors/subject_color_picker.dart';
class CustomEventEditDialog extends StatefulWidget {
@@ -72,9 +73,10 @@ class _CustomEventEditDialogState extends State<CustomEventEditDialog> {
void initState() {
super.initState();
if (_isEditing) {
final s = widget.existingEvent!.startDate;
final e = widget.existingEvent!.endDate;
_isAllDay = isAllDayConvention(s, e);
final existing = widget.existingEvent!;
final s = existing.startDate;
final e = existing.endDate;
_isAllDay = TimetableAppointmentFactory.isCustomEventAllDay(existing);
if (_isAllDay) {
_startTime = _defaultStart;
_endTime = _defaultEnd;
@@ -119,21 +121,6 @@ class _CustomEventEditDialogState extends State<CustomEventEditDialog> {
return (fromMin(start), fromMin(end));
}
/// 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;
Future<void> _save() async {
if (guardDemoAction(context)) return;
if (_name.text.trim().isEmpty) {
@@ -142,7 +129,7 @@ class _CustomEventEditDialogState extends State<CustomEventEditDialog> {
// 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 midnight = _date.dateOnly;
final startDate = _isAllDay ? midnight : _date.withTime(_startTime);
final endDate = _isAllDay ? midnight : _date.withTime(_endTime);
@@ -29,7 +29,7 @@ bool isOutsideSchoolHours(Appointment a) {
}
int dayIndex(DateTime t, DateTime weekStart) =>
DateTime(t.year, t.month, t.day).difference(weekStart).inDays;
t.dateOnly.difference(weekStart).inDays;
class BoundRegion {
final TimeRegion region;
@@ -41,7 +41,7 @@ class BoundRegion {
List<BoundRegion> expandRegionsForDay(List<TimeRegion> regions, DateTime day) {
final result = <BoundRegion>[];
final dayStart = DateTime(day.year, day.month, day.day);
final dayStart = day.dateOnly;
for (final region in regions) {
final isRecurringDaily =
region.recurrenceRule != null &&
@@ -123,11 +123,7 @@ partitionAppointmentsForWeek(
)) {
continue;
}
final idx = DateTime(
occLocal.year,
occLocal.month,
occLocal.day,
).difference(weekStart).inDays;
final idx = dayIndex(occLocal, weekStart);
if (idx < 0 || idx >= 5) continue;
final newStart = DateTime(
occLocal.year,
@@ -5,6 +5,7 @@ import '../../../../api/marianumconnect/models/mc_holiday.dart';
import '../../../../api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.dart';
import '../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
import '../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart';
import '../../../../extensions/date_time.dart';
import '../../../../storage/timetable_settings.dart';
import 'arbitrary_appointment.dart';
import 'lesson_color.dart';
@@ -150,16 +151,8 @@ class TimetableAppointmentFactory {
List<DateTime> _holidayExceptionDates(CustomTimetableEvent event) {
final result = <DateTime>[];
for (final holiday in holidays) {
var day = DateTime(
holiday.startDate.year,
holiday.startDate.month,
holiday.startDate.day,
);
final last = DateTime(
holiday.endDate.year,
holiday.endDate.month,
holiday.endDate.day,
);
var day = holiday.startDate.dateOnly;
final last = holiday.endDate.dateOnly;
while (!day.isAfter(last)) {
result.add(
DateTime(
@@ -171,7 +164,7 @@ class TimetableAppointmentFactory {
event.startDate.second,
),
);
day = DateTime(day.year, day.month, day.day + 1);
day = day.addDays(1);
}
}
return result;
@@ -54,11 +54,8 @@ class CustomEventSheet {
),
TextButton.icon(
onPressed: () {
showDeleteCustomEventDialog(context, event).future.then((
_,
) {
if (!sheetCtx.mounted) return;
Navigator.of(sheetCtx).pop();
showDeleteCustomEventDialog(context, event).then((_) {
if (sheetCtx.mounted) Navigator.of(sheetCtx).pop();
});
},
label: const Text('Löschen'),
@@ -8,12 +8,13 @@ import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart';
import '../../../../widget/confirm_dialog.dart';
import '../../../../widget/demo_restricted.dart';
Completer<void> showDeleteCustomEventDialog(
/// Completes once the event was deleted; never when the dialog is dismissed.
Future<void> showDeleteCustomEventDialog(
BuildContext context,
CustomTimetableEvent event,
) {
final completer = Completer<void>();
if (guardDemoAction(context)) return completer;
if (guardDemoAction(context)) return completer.future;
final bloc = context.read<TimetableBloc>();
ConfirmDialog(
title: 'Termin löschen',
@@ -25,5 +26,5 @@ Completer<void> showDeleteCustomEventDialog(
completer.complete();
},
).asDialog(context);
return completer;
return completer.future;
}
@@ -3,6 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:syncfusion_flutter_calendar/calendar.dart';
import '../../../../api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart';
import '../../../../api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.dart';
import '../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
import '../../../../extensions/date_time.dart';
import '../../../../extensions/text.dart';
@@ -31,12 +32,11 @@ class LessonSheet {
// ändern, ohne dass sich der fremde Plan aktualisiert — daher nur im
// eigenen Plan anbieten. Die Einfärbung (eigene Farben) bleibt.
final bloc = context.read<TimetableBloc>();
final subjects = state.subjects?.result ?? const <McSubject>[];
McSubject? subjectFor(String shortName) =>
subjects.where((s) => s.shortName == shortName).firstOrNull;
final subjectShort = lesson.subjects.firstOrNull;
final subjectEntry = subjectShort == null
? null
: state.subjects?.result
.where((s) => s.shortName == subjectShort)
.firstOrNull;
final subjectEntry = subjectShort == null ? null : subjectFor(subjectShort);
final headerLong = subjectEntry?.longName;
// Bei Stunden ohne Fach (Pausenaufsicht etc.) den Lesson-Type-Titel
// einsetzen — sonst stünde im Header nur ein generisches "?".
@@ -85,9 +85,7 @@ class LessonSheet {
icon: Icons.book_outlined,
label: 'Fächer',
entries: lesson.subjects
.map(
(s) => _line(s, longname: _subjectLongName(state.subjects, s)),
)
.map((s) => _line(s, longname: subjectFor(s)?.longName))
.toList(),
),
_roomTile(context, lesson),
@@ -120,14 +118,10 @@ class LessonSheet {
);
}
final entries = lesson.rooms
.map((name) => (main: _line(name), sub: null as String?))
.toList();
return _listTileWithSubs(
return _listTile(
icon: Icons.room,
label: lesson.rooms.length == 1 ? 'Raum' : 'Räume',
entries: entries,
entries: lesson.rooms.map(_line).toList(),
trailing: trailing,
);
}
@@ -229,43 +223,6 @@ class LessonSheet {
);
}
static Widget _listTileWithSubs({
required IconData icon,
required String label,
required List<({String main, String? sub})> entries,
Widget? trailing,
}) {
if (entries.length == 1) {
final e = entries.first;
return ListTile(
leading: Icon(icon),
title: Text('$label: ${e.main}'),
subtitle: e.sub != null ? Text(e.sub!) : null,
trailing: trailing,
);
}
return ListTile(
leading: Icon(icon),
title: Text(label),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: entries
.expand<Widget>(
(e) => [
Text(e.main),
if (e.sub != null)
Padding(
padding: const EdgeInsets.only(left: 12),
child: Text(e.sub!),
),
],
)
.toList(),
),
trailing: trailing,
);
}
static Widget _listTile({
required IconData icon,
required String label,
@@ -347,15 +304,6 @@ class LessonSheet {
return parts.join(' ');
}
static String? _subjectLongName(dynamic subjects, String shortName) {
if (subjects == null) return null;
final list = subjects.result as Iterable<dynamic>;
for (final s in list) {
if (s.shortName == shortName) return s.longName as String?;
}
return null;
}
static IconData _iconForStatus(String status) {
switch (status) {
case 'CANCELLED':
+83 -122
View File
@@ -10,8 +10,10 @@ import '../../../state/app/modules/foreign_timetable/bloc/foreign_timetable_bloc
import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
import '../../../state/app/modules/timetable/bloc/timetable_bloc.dart';
import '../../../state/app/modules/timetable/bloc/timetable_state.dart';
import '../../../state/app/modules/timetable/bloc/timetable_week_loader.dart';
import '../../../utils/haptics.dart';
import '../../../widget/demo_restricted.dart';
import '../foreign_timetable/element_type_icon.dart';
import 'custom_events/custom_event_edit_dialog.dart';
import 'details/appointment_details_dispatcher.dart';
import 'widgets/timetable_calendar_view.dart';
@@ -33,14 +35,8 @@ class _TimetableState extends State<Timetable> {
/// user's own. Cleared (back to own plan) via the viewing banner.
TimetableElementRef? _selected;
DateTime _initialDisplayDate() => DateTime.now().addDays(2);
void _jumpToToday() {
_calendarKey.currentState?.jumpToToday();
}
bool _isOnInitialWeek(TimetableState state) =>
state.startDate == _mondayOf(_initialDisplayDate());
static bool _isOnInitialWeek(TimetableState? state) =>
state != null && state.startDate == timetableAnchorDate().mondayOfWeek;
Future<void> _openPicker() async {
if (guardDemoAction(context)) return;
@@ -57,20 +53,7 @@ class _TimetableState extends State<Timetable> {
setState(() => _selected = null);
}
void _onAction(_CalendarAction action) {
switch (action) {
case _CalendarAction.addEvent:
showDialog(
context: context,
builder: (_) => const CustomEventEditDialog(),
barrierDismissible: false,
);
case _CalendarAction.viewEvents:
AppRoutes.openCustomEvents(context);
}
}
void _onCreateEventAt(DateTime start, DateTime end) {
void _showEventEditor({DateTime? start, DateTime? end}) {
showDialog(
context: context,
builder: (_) =>
@@ -79,6 +62,15 @@ class _TimetableState extends State<Timetable> {
);
}
void _onAction(_CalendarAction action) {
switch (action) {
case _CalendarAction.addEvent:
_showEventEditor();
case _CalendarAction.viewEvents:
AppRoutes.openCustomEvents(context);
}
}
@override
Widget build(BuildContext context) {
final selected = _selected;
@@ -87,11 +79,8 @@ class _TimetableState extends State<Timetable> {
// (or back to the own plan) tears it down and builds a fresh one.
return BlocProvider<ForeignTimetableBloc>(
key: ValueKey('${selected.type.name}-${selected.id}'),
create: (_) => ForeignTimetableBloc(
type: selected.type,
elementId: selected.id,
title: selected.label,
),
create: (_) =>
ForeignTimetableBloc(type: selected.type, elementId: selected.id),
// Builder gives us a context *below* the provider so the foreign bloc is
// resolvable inside _buildForeignPlan.
child: Builder(
@@ -100,55 +89,68 @@ class _TimetableState extends State<Timetable> {
);
}
AppBar _appBar({
required bool atToday,
required bool canViewForeign,
bool withEventMenu = false,
}) => AppBar(
// Der Kalender scrollt nicht (nur ziehen/reloaden), aber seine internen
// Scrollables feuern ScrollNotifications, die sonst den Material-3
// "scrolled under"-Farbwechsel der AppBar dauerhaft auslösen.
notificationPredicate: (_) => false,
title: const Text('Stunden & Vertretungsplan'),
actions: [
IconButton(
icon: const Icon(Icons.home_outlined),
tooltip: 'Zur aktuellen Woche',
onPressed: atToday
? null
: () => _calendarKey.currentState?.jumpToToday(),
),
if (withEventMenu)
PopupMenuButton<_CalendarAction>(
tooltip: 'Kalendereinträge',
icon: const Icon(Icons.edit_calendar_outlined),
onSelected: _onAction,
itemBuilder: (_) => const [
PopupMenuItem(
value: _CalendarAction.addEvent,
child: ListTile(
title: Text('Kalendereintrag hinzufügen'),
leading: Icon(Icons.add),
),
),
PopupMenuItem(
value: _CalendarAction.viewEvents,
child: ListTile(
title: Text('Kalendereinträge anzeigen'),
leading: Icon(Icons.perm_contact_calendar_outlined),
),
),
],
),
if (canViewForeign)
IconButton(
icon: const Icon(Icons.person_search),
tooltip: 'Anderen Stundenplan öffnen',
onPressed: _openPicker,
),
],
);
Widget _buildOwnPlan(BuildContext context) {
final bloc = context.read<TimetableBloc>();
final loadableState = context.watch<TimetableBloc>().state;
final innerState = loadableState.data;
final atToday = innerState != null && _isOnInitialWeek(innerState);
final atToday = context.select(
(TimetableBloc b) => _isOnInitialWeek(b.state.data),
);
final capabilities = context.watch<CapabilitiesCubit>();
final canViewForeign = capabilities.canViewForeignTimetables;
final teacherPlan = capabilities.isTeacher;
return Scaffold(
appBar: AppBar(
// Der Kalender scrollt nicht (nur ziehen/reloaden), aber seine internen
// Scrollables feuern ScrollNotifications, die sonst den Material-3
// "scrolled under"-Farbwechsel der AppBar dauerhaft auslösen.
notificationPredicate: (_) => false,
title: const Text('Stunden & Vertretungsplan'),
actions: [
IconButton(
icon: const Icon(Icons.home_outlined),
tooltip: 'Zur aktuellen Woche',
onPressed: atToday ? null : _jumpToToday,
),
PopupMenuButton<_CalendarAction>(
tooltip: 'Kalendereinträge',
icon: const Icon(Icons.edit_calendar_outlined),
onSelected: _onAction,
itemBuilder: (_) => const [
PopupMenuItem(
value: _CalendarAction.addEvent,
child: ListTile(
title: Text('Kalendereintrag hinzufügen'),
leading: Icon(Icons.add),
),
),
PopupMenuItem(
value: _CalendarAction.viewEvents,
child: ListTile(
title: Text('Kalendereinträge anzeigen'),
leading: Icon(Icons.perm_contact_calendar_outlined),
),
),
],
),
if (canViewForeign)
IconButton(
icon: const Icon(Icons.person_search),
tooltip: 'Anderen Stundenplan öffnen',
onPressed: _openPicker,
),
],
appBar: _appBar(
atToday: atToday,
canViewForeign: canViewForeign,
withEventMenu: true,
),
body: LoadableStateConsumer<TimetableBloc, TimetableState>(
// Without this predicate the consumer treats the freshly-initialised
@@ -170,7 +172,8 @@ class _TimetableState extends State<Timetable> {
teacherPlan: teacherPlan,
onOpenElement: canViewForeign ? _openElement : null,
),
onCreateEvent: _onCreateEventAt,
onCreateEvent: (start, end) =>
_showEventEditor(start: start, end: end),
customEvents: state.customEvents?.events ?? const [],
showClassInsteadOfTeacher: teacherPlan,
),
@@ -180,44 +183,21 @@ class _TimetableState extends State<Timetable> {
Widget _buildForeignPlan(BuildContext context, TimetableElementRef selected) {
final bloc = context.read<ForeignTimetableBloc>();
final loadableState = context.watch<ForeignTimetableBloc>().state;
final innerState = loadableState.data;
final atToday = innerState != null && _isOnInitialWeek(innerState);
final canViewForeign = context
.watch<CapabilitiesCubit>()
.canViewForeignTimetables;
final atToday = context.select(
(ForeignTimetableBloc b) => _isOnInitialWeek(b.state.data),
);
final canViewForeign = context.select(
(CapabilitiesCubit c) => c.canViewForeignTimetables,
);
final teacherPlan = selected.type == TimetableElementType.teacher;
return Scaffold(
appBar: AppBar(
// Siehe _buildOwnPlan: den scroll-under-Farbwechsel unterdrücken, weil
// der Kalender nicht scrollt, aber ScrollNotifications feuert.
notificationPredicate: (_) => false,
title: const Text('Stunden & Vertretungsplan'),
actions: [
IconButton(
icon: const Icon(Icons.home_outlined),
tooltip: 'Zur aktuellen Woche',
onPressed: atToday ? null : _jumpToToday,
),
if (canViewForeign)
IconButton(
icon: const Icon(Icons.person_search),
tooltip: 'Anderen Stundenplan öffnen',
onPressed: _openPicker,
),
],
),
appBar: _appBar(atToday: atToday, canViewForeign: canViewForeign),
body: Column(
children: [
_ViewingBanner(element: selected, onClose: _backToOwnPlan),
Expanded(
child: LoadableStateConsumer<ForeignTimetableBloc, TimetableState>(
// Foreign plans never carry custom events, so unlike the own-plan
// view we must not require `customEvents` here.
isReady: (state) =>
state.rooms != null &&
state.subjects != null &&
state.schoolHolidays != null,
isReady: (state) => state.hasSchoolReferenceData,
child: (state, _) => TimetableCalendarView(
key: _calendarKey,
state: state,
@@ -229,7 +209,6 @@ class _TimetableState extends State<Timetable> {
teacherPlan: teacherPlan,
onOpenElement: canViewForeign ? _openElement : null,
),
customEvents: const [],
showClassInsteadOfTeacher: teacherPlan,
),
),
@@ -238,11 +217,6 @@ class _TimetableState extends State<Timetable> {
),
);
}
static DateTime _mondayOf(DateTime d) {
final monday = d.subtractDays(d.weekday - 1);
return DateTime(monday.year, monday.month, monday.day);
}
}
/// Slim banner shown at the top of the timetable while a foreign element's plan
@@ -297,7 +271,7 @@ class _ViewingBanner extends StatelessWidget {
padding: const EdgeInsets.fromLTRB(16, 2, 6, 2),
child: Row(
children: [
Icon(_iconFor(element.type), size: 16, color: onColor),
Icon(element.type.icon, size: 16, color: onColor),
const SizedBox(width: 10),
Expanded(
child: Text(
@@ -324,17 +298,4 @@ class _ViewingBanner extends StatelessWidget {
),
);
}
static IconData _iconFor(TimetableElementType type) {
switch (type) {
case TimetableElementType.student:
return Icons.person_outline;
case TimetableElementType.teacher:
return Icons.school_outlined;
case TimetableElementType.room:
return Icons.meeting_room_outlined;
case TimetableElementType.schoolClass:
return Icons.groups_outlined;
}
}
}
@@ -253,20 +253,8 @@ class _DayColumn extends StatelessWidget {
final period = layout.periodAtY(details.localPosition.dy);
if (period == null) return;
final start = DateTime(
date.year,
date.month,
date.day,
period.start.hour,
period.start.minute,
);
final end = DateTime(
date.year,
date.month,
date.day,
period.end.hour,
period.end.minute,
);
final start = date.withTime(period.start);
final end = date.withTime(period.end);
if (_overlapsExistingAppointment(start, end, dayAppts)) return;
Haptics.longPress();
@@ -1,8 +1,8 @@
/// Custom 5-day work-week calendar (replaces Syncfusion's `WorkWeek` view).
///
/// Implementation is split across `calendar/` for readability; everything
/// stays in this single library so private widgets and helpers (`_DayColumn`,
/// `_PeriodLayout`, `_isAllDayLike`, …) can remain library-private.
/// stays in this single library so private widgets (`_DayColumn`,
/// `_WeekGrid`, …) can remain library-private.
library;
import 'dart:async';
@@ -44,11 +44,7 @@ class SpecialRegionsBuilder {
for (var day = rangeStart; !day.isAfter(rangeEnd); day = day.addDays(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));
breakRegions.add(_breakRegion(day.withTime(p.start), p.duration));
}
}
@@ -64,16 +60,8 @@ class SpecialRegionsBuilder {
// on top of each other.
final byDay = <String, _HolidayDay>{};
for (final holiday in holidays.result) {
final startDay = DateTime(
holiday.startDate.year,
holiday.startDate.month,
holiday.startDate.day,
);
final endDay = DateTime(
holiday.endDate.year,
holiday.endDate.month,
holiday.endDate.day,
);
final startDay = holiday.startDate.dateOnly;
final endDay = holiday.endDate.dateOnly;
// 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
@@ -7,6 +7,7 @@ import '../../../../extensions/date_time.dart';
import '../../../../state/app/modules/capabilities/bloc/capabilities_cubit.dart';
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
import '../../../../state/app/modules/timetable/bloc/timetable_state.dart';
import '../../../../state/app/modules/timetable/bloc/timetable_week_loader.dart';
import '../data/arbitrary_appointment.dart';
import '../data/lesson_period_schedule.dart';
import '../data/timetable_appointment_factory.dart';
@@ -63,12 +64,10 @@ class TimetableCalendarViewState extends State<TimetableCalendarView> {
List<TimeRegion>? _regions;
Object? _regionsKey;
DateTime _initialDisplayDate() => DateTime.now().addDays(2);
/// Snaps the calendar back to the current week. Exposed so host pages can
/// wire it to a "today" AppBar action.
void jumpToToday() {
_calendarKey.currentState?.jumpToDate(_initialDisplayDate());
_calendarKey.currentState?.jumpToDate(timetableAnchorDate());
}
List<Appointment> _appointments(TimetableState state) {
@@ -161,7 +160,7 @@ class TimetableCalendarViewState extends State<TimetableCalendarView> {
schedule: schedule,
appointments: appointments,
timeRegions: regions,
initialDate: _initialDisplayDate(),
initialDate: timetableAnchorDate(),
minDate: minDate,
maxDate: maxDate,
onAppointmentTap: widget.onAppointmentTap,
@@ -197,16 +196,14 @@ class TimetableCalendarViewState extends State<TimetableCalendarView> {
baseMin = now.subtractDays(14);
baseMax = now.addDays(7);
}
final effectiveMin = state.accessibleStartDate != null
? (state.accessibleStartDate!.isAfter(baseMin)
? state.accessibleStartDate!
: baseMin)
: baseMin;
final effectiveMax = state.accessibleEndDate != null
? (state.accessibleEndDate!.isBefore(baseMax)
? state.accessibleEndDate!
: baseMax)
: baseMax;
final accessibleStart = state.accessibleStartDate;
final accessibleEnd = state.accessibleEndDate;
final effectiveMin = accessibleStart == null
? baseMin
: _later(accessibleStart, baseMin);
final effectiveMax = accessibleEnd == null
? baseMax
: _earlier(accessibleEnd, baseMax);
final today = DateTime.now().dateOnly;
final todayMonday = today.mondayOfWeek;
final currentWeekEnd = todayMonday.addDays(DateTime.daysPerWeek - 1);
@@ -216,12 +213,12 @@ class TimetableCalendarViewState extends State<TimetableCalendarView> {
final capMax = futureDays == null
? null
: _later(today.addDays(futureDays), currentWeekEnd);
final cappedMin = capMin != null && effectiveMin.isBefore(capMin)
? capMin
: effectiveMin;
final cappedMax = capMax != null && effectiveMax.isAfter(capMax)
? capMax
: effectiveMax;
final cappedMin = capMin == null
? effectiveMin
: _later(effectiveMin, capMin);
final cappedMax = capMax == null
? effectiveMax
: _earlier(effectiveMax, capMax);
// When the resulting range does not cover the current week — the summer gap
// between two school years, or a stale persisted bound — fall back to the
// current week, widened to whatever the capabilities still allow. Otherwise