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:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user