315 lines
11 KiB
Dart
315 lines
11 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/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/app_progress_indicator.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<Timetable> createState() => _TimetableState();
|
|
}
|
|
|
|
class _TimetableState extends State<Timetable> {
|
|
GlobalKey<TimetableCalendarViewState> _calendarKey =
|
|
GlobalKey<TimetableCalendarViewState>();
|
|
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<void> _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<TimetableBloc>().subject;
|
|
if (primary is NoTimetable) return const _NoTimetableView();
|
|
return _buildPlan<TimetableBloc>(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<ScopedTimetableBloc>(
|
|
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<ScopedTimetableBloc>(context),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildPlan<B extends TimetableBloc>(BuildContext context) {
|
|
final bloc = context.read<B>();
|
|
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 = GlobalKey<TimetableCalendarViewState>();
|
|
}
|
|
final innerState = context.watch<B>().state.data;
|
|
final atToday = innerState != null && _isOnInitialWeek(innerState);
|
|
final policy = TimetablePolicy.resolve(
|
|
subject: subject,
|
|
capabilities: context.watch<CapabilitiesCubit>().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<B, 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.
|
|
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).
|
|
class _NoTimetableView extends StatelessWidget {
|
|
const _NoTimetableView();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final capabilities = context.watch<CapabilitiesCubit>().state;
|
|
return Scaffold(
|
|
appBar: AppBar(title: const Text('Stunden & Vertretungsplan')),
|
|
body: capabilities.loaded
|
|
? const NoChildrenPlaceholder()
|
|
: const Center(child: AppProgressIndicator.large()),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 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
|
|
.watch<SettingsCubit>()
|
|
.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;
|
|
}
|
|
}
|
|
}
|