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/foreign_timetable/bloc/foreign_timetable_bloc.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 '../../../utils/haptics.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 { final GlobalKey _calendarKey = GlobalKey(); /// 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 { 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) return _buildOwnPlan(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: (_) => ForeignTimetableBloc( type: selected.type, elementId: selected.id, title: selected.label, ), // Builder gives us a context *below* the provider so the foreign bloc is // resolvable inside _buildForeignPlan. child: Builder( builder: (context) => _buildForeignPlan(context, selected), ), ); } Widget _buildOwnPlan(BuildContext context) { final bloc = context.read(); final loadableState = context.watch().state; final innerState = loadableState.data; final atToday = innerState != null && _isOnInitialWeek(innerState); final canViewForeign = context .watch() .canViewForeignTimetables; return Scaffold( appBar: AppBar( title: const Text('Stunden & Vertretungsplan'), actions: [ IconButton( icon: const Icon(Icons.home_outlined), onPressed: atToday ? null : _jumpToToday, ), PopupMenuButton<_CalendarAction>( 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, ), ], ), body: 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. Telling the consumer that "ready" means having reference // data flips it into the proper error-screen path instead. isReady: (state) => state.hasReferenceData, child: (state, _) => TimetableCalendarView( key: _calendarKey, state: state, onWeekChanged: bloc.changeWeek, onAppointmentTap: (apt) => AppointmentDetailsDispatcher.show(context, state, apt), onCreateEvent: _onCreateEventAt, customEvents: state.customEvents?.events ?? const [], ), ), ); } Widget _buildForeignPlan(BuildContext context, TimetableElementRef selected) { final bloc = context.read(); final loadableState = context.watch().state; final innerState = loadableState.data; final atToday = innerState != null && _isOnInitialWeek(innerState); final canViewForeign = context .watch() .canViewForeignTimetables; return Scaffold( appBar: AppBar( title: const Text('Stunden & Vertretungsplan'), actions: [ IconButton( icon: const Icon(Icons.home_outlined), onPressed: atToday ? null : _jumpToToday, ), if (canViewForeign) IconButton( icon: const Icon(Icons.person_search), tooltip: 'Anderen Stundenplan öffnen', onPressed: _openPicker, ), ], ), body: Column( children: [ _ViewingBanner(element: selected, onClose: _backToOwnPlan), Expanded( child: LoadableStateConsumer( // 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, child: (state, _) => TimetableCalendarView( key: _calendarKey, state: state, onWeekChanged: bloc.changeWeek, onAppointmentTap: (apt) => AppointmentDetailsDispatcher.show(context, state, apt), customEvents: const [], ), ), ), ], ), ); } 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 /// 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; } } }