import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart'; import '../../../extensions/date_time.dart'; import '../../../routing/app_routes.dart'; import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.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_bloc.dart'; import '../../../state/app/modules/timetable/bloc/timetable_state.dart'; import '../../../state/app/modules/timetable/policy/timetable_policy.dart'; import '../../../state/app/modules/timetable/subject/timetable_subject.dart'; import '../../../utils/haptics.dart'; import '../../../widget/child_switcher.dart'; import '../../../widget/demo_restricted.dart'; import 'custom_events/custom_event_edit_dialog.dart'; import 'details/appointment_details_dispatcher.dart'; import 'widgets/timetable_calendar_view.dart'; enum _CalendarAction { addEvent, viewEvents } class Timetable extends StatefulWidget { const Timetable({super.key}); @override State createState() => _TimetableState(); } class _TimetableState extends State { /// One calendar key per subject: a new subject (child switch, foreign plan) /// must not inherit the displayed week of the previous one, but coming back /// to a subject should find its calendar where it was left. final Map> _calendarKeys = {}; GlobalKey _calendarKey = GlobalKey(); TimetableSubject? _calendarSubject; /// When non-null the view shows this element's plan inline instead of the /// 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()); Future _openPicker() async { if (guardDemoAction(context)) return; final ref = await AppRoutes.openElementPicker(context); if (!mounted || ref == null) return; setState(() => _selected = ref); } void _backToOwnPlan() { 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) { showDialog( context: context, builder: (_) => CustomEventEditDialog(initialStart: start, initialEnd: end), barrierDismissible: false, ); } @override Widget build(BuildContext context) { final selected = _selected; if (selected == null) { final primary = context.watch().subject; if (primary is NoTimetable) return const _NoTimetableView(); return _buildPlan(context); } // Scope the foreign bloc to the current selection so switching elements // (or back to the own plan) tears it down and builds a fresh one. return BlocProvider( key: ValueKey('${selected.type.name}-${selected.id}'), create: (_) => ScopedTimetableBloc(subject: ElementTimetable(selected)), // Builder gives us a context *below* the provider so the scoped bloc is // resolvable inside _buildPlan. child: Builder( builder: (context) => _buildPlan(context), ), ); } Widget _buildPlan(BuildContext context) { final bloc = context.read(); final subject = bloc.subject; // A new subject (child switch) must not inherit the displayed week of the // previous calendar state. if (subject != _calendarSubject) { _calendarSubject = subject; _calendarKey = _calendarKeys.putIfAbsent( subject, GlobalKey.new, ); } final innerState = context.watch().state.data; final atToday = innerState != null && _isOnInitialWeek(innerState); final policy = TimetablePolicy.resolve( subject: subject, capabilities: context.watch().state, ); 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: [ // Hides itself unless a guardian has more than one child. const ChildSwitcher(), IconButton( icon: const Icon(Icons.home_outlined), tooltip: 'Zur aktuellen Woche', onPressed: atToday ? null : _jumpToToday, ), if (policy.canManageCustomEvents) 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 (policy.canOpenForeign) IconButton( icon: const Icon(Icons.person_search), tooltip: 'Anderen Stundenplan öffnen', onPressed: _openPicker, ), ], ), body: Column( children: [ if (subject case ElementTimetable(:final element)) _ViewingBanner(element: element, onClose: _backToOwnPlan), Expanded( child: LoadableStateConsumer( // Without this predicate the consumer treats the freshly- // initialised empty TimetableState as "has content" and only // shows the error bar on top — but the calendar view collapses // to `SizedBox.shrink()` while the reference data is missing, // leaving the user with a blank screen. isReady: (state) => state.isReady( needsCustomEvents: subject.supportsCustomEvents, ), child: (state, _) => TimetableCalendarView( key: _calendarKey, state: state, onWeekChanged: bloc.changeWeek, onAppointmentTap: (apt) => AppointmentDetailsDispatcher.show( context, state, apt, canEditSubjectColor: policy.canEditSubjectColors, ), onCreateEvent: policy.canManageCustomEvents ? _onCreateEventAt : null, customEvents: state.customEvents?.events ?? const [], showClassInsteadOfTeacher: policy.showClassInsteadOfTeacher, ), ), ), ], ), ); } static DateTime _mondayOf(DateTime d) { final monday = d.subtractDays(d.weekday - 1); return DateTime(monday.year, monday.month, monday.day); } } /// Shown instead of a plan when the session has none, i.e. a guardian whose /// children are not known (yet). [NoChildrenPlaceholder] tells a pending or /// failed capability load apart from a confirmed empty list. class _NoTimetableView extends StatelessWidget { const _NoTimetableView(); @override Widget build(BuildContext context) => Scaffold( appBar: AppBar(title: const Text('Stunden & Vertretungsplan')), body: const NoChildrenPlaceholder(), ); } /// Slim banner shown at the top of the timetable while a foreign element's plan /// is being viewed. Displays which element is shown, lets the user star it, and /// offers a one-tap return to the own plan. class _ViewingBanner extends StatelessWidget { final TimetableElementRef element; final VoidCallback onClose; const _ViewingBanner({required this.element, required this.onClose}); void _toggleFavorite(BuildContext context) { Haptics.selection(); context .read() .val(write: true) .timetableFavoritesSettings .toggle(element.type, element.id, element.label); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final isFavorite = context .watch() .val() .timetableFavoritesSettings .isFavorite(element.type, element.id); final onColor = theme.colorScheme.onSecondaryContainer; // Compact icon button: ~32px square, no extra padding, so the banner stays // slim instead of inheriting the default 48px touch target height. Widget compactButton({ required IconData icon, required String tooltip, required VoidCallback onPressed, }) => IconButton( icon: Icon(icon), iconSize: 18, color: onColor, tooltip: tooltip, visualDensity: VisualDensity.compact, padding: EdgeInsets.zero, constraints: const BoxConstraints(minWidth: 32, minHeight: 32), onPressed: onPressed, ); return Material( color: theme.colorScheme.secondaryContainer, child: Padding( padding: const EdgeInsets.fromLTRB(16, 2, 6, 2), child: Row( children: [ Icon(_iconFor(element.type), size: 16, color: onColor), const SizedBox(width: 10), Expanded( child: Text( element.label, style: theme.textTheme.labelLarge?.copyWith(color: onColor), overflow: TextOverflow.ellipsis, ), ), compactButton( icon: isFavorite ? Icons.star : Icons.star_border, tooltip: isFavorite ? 'Favorit entfernen' : 'Als Favorit markieren', onPressed: () => _toggleFavorite(context), ), const SizedBox(width: 4), compactButton( icon: Icons.close, tooltip: 'Zurück zum eigenen Plan', onPressed: onClose, ), ], ), ), ); } 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; } } }