Files
Client/lib/view/pages/timetable/timetable.dart
T

302 lines
10 KiB
Dart

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 '../../../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';
enum _CalendarAction { addEvent, viewEvents }
class Timetable extends StatefulWidget {
const Timetable({super.key});
@override
State<Timetable> createState() => _TimetableState();
}
class _TimetableState extends State<Timetable> {
final GlobalKey<TimetableCalendarViewState> _calendarKey =
GlobalKey<TimetableCalendarViewState>();
/// 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;
static bool _isOnInitialWeek(TimetableState? state) =>
state != null && state.startDate == timetableAnchorDate().mondayOfWeek;
Future<void> _openPicker() async {
if (guardDemoAction(context)) return;
final ref = await AppRoutes.openElementPicker(context);
if (!mounted || ref == null) return;
_openElement(ref);
}
void _openElement(TimetableElementRef ref) {
setState(() => _selected = ref);
}
void _backToOwnPlan() {
setState(() => _selected = null);
}
void _showEventEditor({DateTime? start, DateTime? end}) {
showDialog(
context: context,
builder: (_) =>
CustomEventEditDialog(initialStart: start, initialEnd: end),
barrierDismissible: false,
);
}
void _onAction(_CalendarAction action) {
switch (action) {
case _CalendarAction.addEvent:
_showEventEditor();
case _CalendarAction.viewEvents:
AppRoutes.openCustomEvents(context);
}
}
@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<ForeignTimetableBloc>(
key: ValueKey('${selected.type.name}-${selected.id}'),
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(
builder: (context) => _buildForeignPlan(context, selected),
),
);
}
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 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(
atToday: atToday,
canViewForeign: canViewForeign,
withEventMenu: true,
),
body: LoadableStateConsumer<TimetableBloc, TimetableState>(
// 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,
canEditSubjectColor: true,
teacherPlan: teacherPlan,
onOpenElement: canViewForeign ? _openElement : null,
),
onCreateEvent: (start, end) =>
_showEventEditor(start: start, end: end),
customEvents: state.customEvents?.events ?? const [],
showClassInsteadOfTeacher: teacherPlan,
),
),
);
}
Widget _buildForeignPlan(BuildContext context, TimetableElementRef selected) {
final bloc = context.read<ForeignTimetableBloc>();
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(atToday: atToday, canViewForeign: canViewForeign),
body: Column(
children: [
_ViewingBanner(element: selected, onClose: _backToOwnPlan),
Expanded(
child: LoadableStateConsumer<ForeignTimetableBloc, TimetableState>(
isReady: (state) => state.hasSchoolReferenceData,
child: (state, _) => TimetableCalendarView(
key: _calendarKey,
state: state,
onWeekChanged: bloc.changeWeek,
onAppointmentTap: (apt) => AppointmentDetailsDispatcher.show(
context,
state,
apt,
teacherPlan: teacherPlan,
onOpenElement: canViewForeign ? _openElement : null,
),
showClassInsteadOfTeacher: teacherPlan,
),
),
),
],
),
);
}
}
/// 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<SettingsCubit>()
.val(write: true)
.timetableFavoritesSettings
.toggle(element.type, element.id, element.label);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isFavorite = context.select(
(SettingsCubit c) => c.state.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(element.type.icon, 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,
),
],
),
),
);
}
}