implemented foreign timetable support for students, teachers, rooms, and classes, including a searchable element picker with favorites support, introduced a capabilities system for feature gating, refactored the timetable UI into a reusable TimetableCalendarView component, and redesigned the chat input field with a unified emoji picker and integrated attachment actions.

This commit is contained in:
2026-05-31 21:29:16 +02:00
parent 6e12da08c0
commit b6d06dd3b4
41 changed files with 2325 additions and 290 deletions
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:syncfusion_flutter_calendar/calendar.dart';
import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart';
import '../../../../state/app/modules/timetable/bloc/timetable_state.dart';
import '../data/arbitrary_appointment.dart';
import 'custom_event_sheet.dart';
import 'lesson_sheet.dart';
@@ -9,15 +9,14 @@ import 'lesson_sheet.dart';
class AppointmentDetailsDispatcher {
static void show(
BuildContext context,
TimetableBloc bloc,
TimetableState? state,
Appointment appointment,
) {
final id = appointment.id;
if (id is! ArbitraryAppointment) return;
id.when(
lesson: (entry) =>
LessonSheet.show(context, bloc, appointment, entry),
lesson: (entry) => LessonSheet.show(context, state, appointment, entry),
custom: (event) => CustomEventSheet.show(context, event),
);
}
@@ -5,7 +5,7 @@ import '../../../../api/marianumconnect/queries/timetable_get_week/timetable_get
import '../../../../extensions/date_time.dart';
import '../../../../extensions/text.dart';
import '../../../../routing/app_routes.dart';
import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart';
import '../../../../state/app/modules/timetable/bloc/timetable_state.dart';
import '../../../../widget/debug/debug_tile.dart';
import '../../../../widget/details_bottom_sheet.dart';
import '../data/lesson_type_label.dart';
@@ -13,11 +13,10 @@ import '../data/lesson_type_label.dart';
class LessonSheet {
static void show(
BuildContext context,
TimetableBloc bloc,
TimetableState? state,
Appointment appointment,
McTimetableEntry lesson,
) {
final state = bloc.state.data;
if (state == null) return;
final subjectShort = lesson.subjects.firstOrNull;
+211 -146
View File
@@ -1,21 +1,19 @@
import 'package:flutter/material.dart';
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 '../../../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 '../../../storage/timetable_settings.dart';
import '../../../utils/haptics.dart';
import 'custom_events/custom_event_edit_dialog.dart';
import 'data/arbitrary_appointment.dart';
import 'data/lesson_period_schedule.dart';
import 'data/timetable_appointment_factory.dart';
import 'details/appointment_details_dispatcher.dart';
import 'widgets/custom_workweek_calendar.dart';
import 'widgets/special_regions_builder.dart';
import 'widgets/timetable_calendar_view.dart';
enum _CalendarAction { addEvent, viewEvents }
@@ -27,17 +25,30 @@ class Timetable extends StatefulWidget {
}
class _TimetableState extends State<Timetable> {
final GlobalKey<CustomWorkWeekCalendarState> _calendarKey =
GlobalKey<CustomWorkWeekCalendarState>();
final GlobalKey<TimetableCalendarViewState> _calendarKey =
GlobalKey<TimetableCalendarViewState>();
List<Appointment>? _cachedAppointments;
int? _lastDataVersion;
TimetableSettings? _lastTimetableSettings;
/// 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?.jumpToDate(_initialDisplayDate());
_calendarKey.currentState?.jumpToToday();
}
bool _isOnInitialWeek(TimetableState state) =>
state.startDate == _mondayOf(_initialDisplayDate());
Future<void> _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) {
@@ -53,43 +64,44 @@ class _TimetableState extends State<Timetable> {
}
}
List<Appointment> _appointments(TimetableState state) {
final timetableSettings = context
.watch<SettingsCubit>()
.val()
.timetableSettings;
if (_cachedAppointments != null &&
_lastDataVersion == state.dataVersion &&
identical(_lastTimetableSettings, timetableSettings)) {
return _cachedAppointments!;
}
_lastDataVersion = state.dataVersion;
_lastTimetableSettings = timetableSettings;
return _cachedAppointments = TimetableAppointmentFactory(
lessons: state.getAllKnownLessons().toList(),
customEvents: state.customEvents?.events ?? const [],
subjects: state.subjects?.result ?? const [],
settings: timetableSettings,
now: DateTime.now(),
).build();
void _onCreateEventAt(DateTime start, DateTime end) {
showDialog(
context: context,
builder: (_) =>
CustomEventEditDialog(initialStart: start, initialEnd: end),
barrierDismissible: false,
);
}
bool _isCrossedOut(Appointment appointment) {
final id = appointment.id;
if (id is LessonAppointment) return id.entry.status == 'CANCELLED';
return false;
}
bool _isOnInitialWeek(TimetableState state) =>
state.startDate == _mondayOf(_initialDisplayDate());
@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,
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<TimetableBloc>();
final loadableState = context.watch<TimetableBloc>().state;
final innerState = loadableState.data;
final atToday = innerState != null && _isOnInitialWeek(innerState);
final canViewForeign = context
.watch<CapabilitiesCubit>()
.canViewForeignTimetables;
return Scaffold(
appBar: AppBar(
title: const Text('Stunden & Vertretungsplan'),
@@ -118,125 +130,178 @@ class _TimetableState extends State<Timetable> {
),
],
),
if (canViewForeign)
IconButton(
icon: const Icon(Icons.person_search),
tooltip: 'Anderen Stundenplan öffnen',
onPressed: _openPicker,
),
],
),
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 `_calendar` 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.
// 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, _) => _calendar(state, bloc),
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 _calendar(TimetableState state, TimetableBloc bloc) {
if (!state.hasReferenceData) return const SizedBox.shrink();
final schedule = LessonPeriodSchedule.fromState(state);
final appointments = _appointments(state);
final regions = SpecialRegionsBuilder(
holidays: state.schoolHolidays!,
schedule: schedule,
colorScheme: Theme.of(context).colorScheme,
disabledColor: Theme.of(context).disabledColor,
).build();
// Scroll bounds follow the Webuntis school-year API: the calendar lets
// the user navigate every week the server has data for. A two-week
// fallback is used only while the school-year payload hasn't loaded yet
// (first launch / offline), so the calendar still mounts.
final (minDate, maxDate) = _scrollBounds(state);
return CustomWorkWeekCalendar(
key: _calendarKey,
schedule: schedule,
appointments: appointments,
timeRegions: regions,
initialDate: _initialDisplayDate(),
minDate: minDate,
maxDate: maxDate,
onAppointmentTap: (apt) =>
AppointmentDetailsDispatcher.show(context, bloc, apt),
onWeekChanged: (start, end) => bloc.changeWeek(start, end),
isCrossedOut: _isCrossedOut,
onCreateEvent: _onCreateEventAt,
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;
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<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,
child: (state, _) => TimetableCalendarView(
key: _calendarKey,
state: state,
onWeekChanged: bloc.changeWeek,
onAppointmentTap: (apt) =>
AppointmentDetailsDispatcher.show(context, state, apt),
customEvents: const [],
),
),
),
],
),
);
}
void _onCreateEventAt(DateTime start, DateTime end) {
showDialog(
context: context,
builder: (_) =>
CustomEventEditDialog(initialStart: start, initialEnd: end),
barrierDismissible: false,
);
}
/// Hard caps applied on top of whatever Webuntis would allow. Even if the
/// school year (or a stale persisted bound) would let the user scroll
/// further, we never expose more than this much around the current week —
/// containment for any future date-math bug that might otherwise teleport
/// the user months away from today.
static const int _maxWeeksBack = 4;
static const int _maxWeeksForward = 2;
/// Returns the (minDate, maxDate) the user is allowed to scroll between.
/// Starts from the Webuntis school year (or a tight window when that
/// hasn't loaded yet), tightens by anything the bloc has learned from
/// `-7004 no allowed date` errors during scroll — so the user can't
/// slide off into territory Webuntis would refuse anyway — and finally
/// clamps to a fixed window around today.
///
/// minDate is snapped *forward* to the next Monday because the calendar's
/// internal `_mondayOf()` would otherwise pull a mid-week minDate back
/// into the just-rejected week. maxDate is passed through unsnapped —
/// `_mondayOf()` correctly walks back to the Monday of its own week,
/// which is the last fully-allowed week.
(DateTime, DateTime) _scrollBounds(TimetableState state) {
final year = state.schoolyear;
final DateTime baseMin;
final DateTime baseMax;
if (year != null) {
baseMin = year.startDate;
baseMax = year.endDate;
} else {
final now = DateTime.now();
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 todayMonday = _mondayOf(DateTime.now());
final cappedMin = effectiveMin.isBefore(
todayMonday.subtractDays(_maxWeeksBack * 7),
)
? todayMonday.subtractDays(_maxWeeksBack * 7)
: effectiveMin;
final cappedMax = effectiveMax.isAfter(
todayMonday.addDays(_maxWeeksForward * 7 + 6),
)
? todayMonday.addDays(_maxWeeksForward * 7 + 6)
: effectiveMax;
final daysToMonday =
(DateTime.monday - cappedMin.weekday) % DateTime.daysPerWeek;
final mondayMin = cappedMin.addDays(daysToMonday);
return (mondayMin, cappedMax);
}
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<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;
}
}
}
@@ -0,0 +1,180 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:syncfusion_flutter_calendar/calendar.dart';
import '../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart';
import '../../../../extensions/date_time.dart';
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
import '../../../../state/app/modules/timetable/bloc/timetable_state.dart';
import '../../../../storage/timetable_settings.dart';
import '../data/arbitrary_appointment.dart';
import '../data/lesson_period_schedule.dart';
import '../data/timetable_appointment_factory.dart';
import 'custom_workweek_calendar.dart';
import 'special_regions_builder.dart';
/// Renders a weekly timetable from a [TimetableState]. Shared by the user's own
/// plan and the foreign-element view; the only differences are which custom
/// events to overlay (none for foreign plans) and whether tapping an empty slot
/// can create an event ([onCreateEvent] is null for read-only foreign plans).
///
/// The week navigation and appointment-tap callbacks are supplied by the host
/// page so each can route them to its own bloc.
class TimetableCalendarView extends StatefulWidget {
final TimetableState state;
final void Function(DateTime weekStart, DateTime weekEnd) onWeekChanged;
final void Function(Appointment appointment) onAppointmentTap;
final void Function(DateTime start, DateTime end)? onCreateEvent;
final List<CustomTimetableEvent> customEvents;
const TimetableCalendarView({
super.key,
required this.state,
required this.onWeekChanged,
required this.onAppointmentTap,
this.onCreateEvent,
this.customEvents = const [],
});
@override
State<TimetableCalendarView> createState() => TimetableCalendarViewState();
}
class TimetableCalendarViewState extends State<TimetableCalendarView> {
final GlobalKey<CustomWorkWeekCalendarState> _calendarKey =
GlobalKey<CustomWorkWeekCalendarState>();
List<Appointment>? _cachedAppointments;
int? _lastDataVersion;
TimetableSettings? _lastTimetableSettings;
List<CustomTimetableEvent>? _lastCustomEvents;
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());
}
bool isOnInitialWeek() =>
widget.state.startDate == _mondayOf(_initialDisplayDate());
List<Appointment> _appointments(TimetableState state) {
final timetableSettings = context
.watch<SettingsCubit>()
.val()
.timetableSettings;
if (_cachedAppointments != null &&
_lastDataVersion == state.dataVersion &&
identical(_lastTimetableSettings, timetableSettings) &&
identical(_lastCustomEvents, widget.customEvents)) {
return _cachedAppointments!;
}
_lastDataVersion = state.dataVersion;
_lastTimetableSettings = timetableSettings;
_lastCustomEvents = widget.customEvents;
return _cachedAppointments = TimetableAppointmentFactory(
lessons: state.getAllKnownLessons().toList(),
customEvents: widget.customEvents,
subjects: state.subjects?.result ?? const [],
settings: timetableSettings,
now: DateTime.now(),
).build();
}
bool _isCrossedOut(Appointment appointment) {
final id = appointment.id;
if (id is LessonAppointment) return id.entry.status == 'CANCELLED';
return false;
}
@override
Widget build(BuildContext context) {
final state = widget.state;
// Reference data is gated by the host's LoadableStateConsumer isReady
// predicate, but guard the one hard dereference (schoolHolidays) so a
// transient null can never crash the build.
if (state.schoolHolidays == null) return const SizedBox.shrink();
final schedule = LessonPeriodSchedule.fromState(state);
final appointments = _appointments(state);
final regions = SpecialRegionsBuilder(
holidays: state.schoolHolidays!,
schedule: schedule,
colorScheme: Theme.of(context).colorScheme,
disabledColor: Theme.of(context).disabledColor,
).build();
final (minDate, maxDate) = _scrollBounds(state);
return CustomWorkWeekCalendar(
key: _calendarKey,
schedule: schedule,
appointments: appointments,
timeRegions: regions,
initialDate: _initialDisplayDate(),
minDate: minDate,
maxDate: maxDate,
onAppointmentTap: widget.onAppointmentTap,
onWeekChanged: widget.onWeekChanged,
isCrossedOut: _isCrossedOut,
onCreateEvent: widget.onCreateEvent,
);
}
/// Hard caps applied on top of whatever Webuntis would allow. Even if the
/// school year (or a stale persisted bound) would let the user scroll
/// further, we never expose more than this much around the current week.
static const int _maxWeeksBack = 4;
static const int _maxWeeksForward = 2;
/// Returns the (minDate, maxDate) the user is allowed to scroll between.
/// Starts from the Webuntis school year (or a tight window when that hasn't
/// loaded yet), tightens by anything the bloc has learned from past denials,
/// and finally clamps to a fixed window around today.
(DateTime, DateTime) _scrollBounds(TimetableState state) {
final year = state.schoolyear;
final DateTime baseMin;
final DateTime baseMax;
if (year != null) {
baseMin = year.startDate;
baseMax = year.endDate;
} else {
final now = DateTime.now();
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 todayMonday = _mondayOf(DateTime.now());
final cappedMin = effectiveMin.isBefore(
todayMonday.subtractDays(_maxWeeksBack * 7),
)
? todayMonday.subtractDays(_maxWeeksBack * 7)
: effectiveMin;
final cappedMax = effectiveMax.isAfter(
todayMonday.addDays(_maxWeeksForward * 7 + 6),
)
? todayMonday.addDays(_maxWeeksForward * 7 + 6)
: effectiveMax;
final daysToMonday =
(DateTime.monday - cappedMin.weekday) % DateTime.daysPerWeek;
final mondayMin = cappedMin.addDays(daysToMonday);
return (mondayMin, cappedMax);
}
static DateTime _mondayOf(DateTime d) {
final monday = d.subtractDays(d.weekday - 1);
return DateTime(monday.year, monday.month, monday.day);
}
}