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
@@ -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);
}
}