migrated timetable integration from WebUntis to the MarianumConnect API, implementing a Dio-based client with bearer token authentication, background session validation, and auto-refresh logic.

This commit is contained in:
2026-05-23 17:32:42 +02:00
parent 2858f910c9
commit 93b9929f8f
106 changed files with 2739 additions and 2624 deletions
@@ -1,21 +1,21 @@
import '../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
import '../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart';
import '../../../../api/webuntis/queries/get_timetable/get_timetable_response.dart';
sealed class ArbitraryAppointment {
const ArbitraryAppointment();
T when<T>({
required T Function(GetTimetableResponseObject lesson) webuntis,
required T Function(McTimetableEntry lesson) lesson,
required T Function(CustomTimetableEvent event) custom,
}) => switch (this) {
WebuntisAppointment(:final lesson) => webuntis(lesson),
LessonAppointment(:final entry) => lesson(entry),
CustomAppointment(:final event) => custom(event),
};
}
class WebuntisAppointment extends ArbitraryAppointment {
final GetTimetableResponseObject lesson;
const WebuntisAppointment(this.lesson);
class LessonAppointment extends ArbitraryAppointment {
final McTimetableEntry entry;
const LessonAppointment(this.entry);
}
class CustomAppointment extends ArbitraryAppointment {
@@ -282,7 +282,7 @@ class LaidOutOverflow extends LaidOutCell {
int _appointmentPriority(Appointment a) {
final id = a.id;
if (id is CustomAppointment) return 0;
if (id is WebuntisAppointment && id.lesson.code == 'cancelled') return 1;
if (id is LessonAppointment && id.entry.status == 'CANCELLED') return 1;
return 2;
}
@@ -9,6 +9,10 @@ class LessonColor {
static const Color irregular = Color(0xff8F19B3);
static const Color teacherChanged = Color(0xFF29639B);
static const Color event = Color(0xff2E7D32);
// Petrol-Türkis für Sonder-Lesson-Types (Aufsicht, Sprechstunde, …) —
// hebt sie deutlich von regulärem Unterricht (Marianum-Rot) und Events
// (Grün) ab, ohne den Status-übergreifenden Farb-Code zu verwässern.
static const Color duty = Color(0xff00796B);
static const Color parseFallback = Color(0xff404040);
static Color forStatus(LessonStatus status) {
@@ -21,6 +25,8 @@ class LessonColor {
return irregular;
case LessonStatus.teacherChanged:
return teacherChanged;
case LessonStatus.duty:
return duty;
case LessonStatus.past:
case LessonStatus.regular:
return regular;
@@ -0,0 +1,72 @@
import '../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
/// Combines back-to-back lessons with identical subject/room/teacher/status
/// into a single visual block. Shared by the calendar tile builder and the
/// home-widget data mapper so both surfaces show the same merged spans.
///
/// Built as a new list rather than mutating inputs — earlier in-place merges
/// extended merged blocks further on every rebuild when the same lesson
/// objects were observed again.
class LessonMerger {
const LessonMerger._();
static const Duration defaultMaxGap = Duration(minutes: 5);
static List<McTimetableEntry> merge(
List<McTimetableEntry> input, {
Duration maxGap = defaultMaxGap,
}) {
if (input.isEmpty) return const [];
final sorted = [...input]
..sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
final merged = <McTimetableEntry>[];
for (final current in sorted) {
if (merged.isNotEmpty && _canMerge(merged.last, current, maxGap)) {
final prev = merged.removeLast();
merged.add(_extendedEnd(prev, current.endTime));
} else {
merged.add(current);
}
}
return merged;
}
static bool _canMerge(
McTimetableEntry a,
McTimetableEntry b,
Duration maxGap,
) {
if (a.subjects.firstOrNull != b.subjects.firstOrNull) return false;
if (a.rooms.firstOrNull != b.rooms.firstOrNull) return false;
if (a.teachers.firstOrNull?.shortName !=
b.teachers.firstOrNull?.shortName) {
return false;
}
if (a.status != b.status) return false;
// Lower bound on the gap — without it, two identical-metadata lessons that
// overlap in time would silently collapse into one.
final gap = b.startDateTime.difference(a.endDateTime);
return !gap.isNegative && gap <= maxGap;
}
static McTimetableEntry _extendedEnd(
McTimetableEntry source,
DateTime newEndTime,
) => McTimetableEntry(
id: source.id,
date: source.date,
startTime: source.startTime,
endTime: newEndTime,
subjects: source.subjects,
teachers: source.teachers,
rooms: source.rooms,
classNames: source.classNames,
lessonType: source.lessonType,
status: source.status,
substitutionText: source.substitutionText,
lessonText: source.lessonText,
infoText: source.infoText,
);
}
@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import '../../../../api/webuntis/queries/get_timegrid_units/get_timegrid_units_response.dart';
import '../../../../api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid_response.dart';
import '../../../../state/app/modules/timetable/bloc/timetable_state.dart';
class LessonPeriod {
@@ -28,22 +28,30 @@ class LessonPeriodSchedule {
const LessonPeriodSchedule(this.periods);
static LessonPeriodSchedule? fromApi(GetTimegridUnitsResponse response) {
final canonical = response.result.firstWhere(
(d) => d.day == 1,
orElse: () => response.result.isNotEmpty
? response.result.first
: GetTimegridUnitsResponseDay(0, []),
);
if (canonical.timeUnits.isEmpty) return null;
static LessonPeriodSchedule? fromApi(TimetableGetTimegridResponse response) {
// The Marianum-Connect endpoint returns one entry per (weekday, unit). The
// school's bell schedule is identical MonFri, so we pick Monday as the
// canonical day and fall back to the first available weekday if Monday is
// missing.
final monday = response.result
.where((u) => u.dayOfWeek == McDayOfWeek.monday)
.toList();
final source = monday.isNotEmpty ? monday : response.result;
if (source.isEmpty) return null;
final periods =
canonical.timeUnits
source
.map(
(u) => LessonPeriod(
name: u.name,
start: _fromHHMM(u.startTime),
end: _fromHHMM(u.endTime),
name: u.label,
start: TimeOfDay(
hour: u.startTime.hour,
minute: u.startTime.minute,
),
end: TimeOfDay(
hour: u.endTime.hour,
minute: u.endTime.minute,
),
),
)
.toList()
@@ -144,7 +152,4 @@ class LessonPeriodSchedule {
}
return LessonPeriodSchedule(result);
}
static TimeOfDay _fromHHMM(int hhmm) =>
TimeOfDay(hour: hhmm ~/ 100, minute: hhmm % 100);
}
@@ -1,32 +1,40 @@
import '../../../../api/webuntis/queries/get_timetable/get_timetable_response.dart';
import '../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
enum LessonStatus {
cancelled,
event,
irregular,
teacherChanged,
duty,
past,
ongoing,
regular,
}
class LessonStatusClassifier {
/// Mirrors the legacy Webuntis classifier: cancelled trumps everything,
/// then event (subject-less lessons such as Wandertag), then irregular
/// (status from the backend or a slot without an assigned teacher), then
/// teacherChanged when the backend reports a substitution swap, then
/// duty (Aufsicht/Sprechstunde/…) so they stand out from regular
/// classroom lessons, then the time-based past/ongoing/regular states.
static LessonStatus classify(
GetTimetableResponseObject lesson,
McTimetableEntry entry,
DateTime startTime,
DateTime endTime,
DateTime now, {
bool isEvent = false,
bool isDuty = false,
}) {
if (lesson.code == 'cancelled') return LessonStatus.cancelled;
if (entry.status == 'CANCELLED') return LessonStatus.cancelled;
if (isEvent) return LessonStatus.event;
if (lesson.code == 'irregular' ||
(lesson.te.isNotEmpty && lesson.te.first.id == 0)) {
if (entry.status == 'IRREGULAR' || entry.teachers.isEmpty) {
return LessonStatus.irregular;
}
if (lesson.te.any((t) => t.orgname != null)) {
if (entry.teachers.any((t) => (t.originalShortName ?? '').isNotEmpty)) {
return LessonStatus.teacherChanged;
}
if (isDuty) return LessonStatus.duty;
if (endTime.isBefore(now)) return LessonStatus.past;
if (startTime.isBefore(now) && endTime.isAfter(now)) {
return LessonStatus.ongoing;
@@ -0,0 +1,33 @@
import '../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
/// Derives a human-readable title from the Marianum-Connect `lessonType` for
/// lessons that have no subject of their own (Aufsicht, Sprechstunde, …).
/// Shared between the calendar tile and the lesson detail sheet so both
/// surfaces show the same wording, with the room rendered first so it
/// survives a truncated tile.
class LessonTypeLabel {
static const String fallback = 'Event';
static String forEntry(McTimetableEntry lesson) {
final base = baseLabel(lesson.lessonType);
final room = (lesson.rooms.firstOrNull ?? '').trim();
return room.isEmpty ? base : '$room $base';
}
/// Returns just the type wording without the room — useful when the caller
/// renders the room separately (e.g. as a subtitle line).
static String baseLabel(String lessonType) {
switch (lessonType) {
case 'BREAK_SUPERVISION':
return 'Aufsicht';
case 'OFFICE_HOUR':
return 'Sprechstunde';
case 'STANDBY':
return 'Bereitschaft';
case 'EXAM':
return 'Prüfung';
default:
return fallback;
}
}
}
@@ -1,31 +1,27 @@
import 'package:collection/collection.dart';
import 'package:syncfusion_flutter_calendar/calendar.dart';
import '../../../../api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.dart';
import '../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
import '../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart';
import '../../../../api/webuntis/queries/get_rooms/get_rooms_response.dart';
import '../../../../api/webuntis/queries/get_subjects/get_subjects_response.dart';
import '../../../../api/webuntis/queries/get_timetable/get_timetable_response.dart';
import '../../../../storage/timetable_settings.dart';
import '../custom_events/custom_event_colors.dart';
import 'arbitrary_appointment.dart';
import 'lesson_color.dart';
import 'lesson_status.dart';
import 'lesson_type_label.dart';
import 'rrule_with_exceptions.dart';
import 'timetable_name_mode.dart';
import 'webuntis_time.dart';
class TimetableAppointmentFactory {
final List<GetTimetableResponseObject> lessons;
final List<McTimetableEntry> lessons;
final List<CustomTimetableEvent> customEvents;
final GetRoomsResponse rooms;
final GetSubjectsResponse subjects;
final List<McSubject> subjects;
final TimetableSettings settings;
final DateTime now;
TimetableAppointmentFactory({
required this.lessons,
required this.customEvents,
required this.rooms,
required this.subjects,
required this.settings,
required this.now,
@@ -41,37 +37,40 @@ class TimetableAppointmentFactory {
];
}
Appointment _lessonToAppointment(GetTimetableResponseObject lesson) {
Appointment _lessonToAppointment(McTimetableEntry lesson) {
try {
final startTime = WebuntisTime.parse(lesson.date, lesson.startTime);
final endTime = WebuntisTime.parse(lesson.date, lesson.endTime);
final subject = subjects.result.firstWhereOrNull(
(s) => s.id == lesson.su.firstOrNull?.id,
);
final startTime = lesson.startDateTime;
final endTime = lesson.endDateTime;
final subjectShortName = lesson.subjects.firstOrNull;
// "Event"-Status nur, wenn auch kein bekannter Sonder-Lesson-Type vorliegt
// — Aufsicht/Sprechstunde/etc. werden sonst grün statt eigenständig
// eingefärbt.
final isEvent = subjectShortName == null && lesson.lessonType == 'LESSON';
final isDuty = lesson.lessonType != 'LESSON';
final status = LessonStatusClassifier.classify(
lesson,
startTime,
endTime,
now,
isEvent: subject == null,
isEvent: isEvent,
isDuty: isDuty,
);
return Appointment(
id: WebuntisAppointment(lesson),
id: LessonAppointment(lesson),
startTime: startTime,
endTime: endTime,
subject: _subjectName(lesson, subject),
subject: _subjectName(subjectShortName, lesson),
location: _locationLabel(lesson),
notes: lesson.activityType,
color: LessonColor.forStatus(status),
);
} catch (_) {
return Appointment(
id: WebuntisAppointment(lesson),
startTime: WebuntisTime.parse(lesson.date, lesson.startTime),
endTime: WebuntisTime.parse(lesson.date, lesson.endTime),
id: LessonAppointment(lesson),
startTime: lesson.startDateTime,
endTime: lesson.endDateTime,
subject: 'Änderung',
notes: lesson.info,
notes: lesson.infoText,
location: 'Unbekannt',
color: LessonColor.parseFallback,
startTimeZone: '',
@@ -147,32 +146,48 @@ class TimetableAppointmentFactory {
e.second == 0;
}
String _subjectName(
GetTimetableResponseObject lesson,
GetSubjectsResponseObject? subject,
) {
if (subject == null) return 'Event';
final name = switch (settings.timetableNameMode) {
TimetableNameMode.name => subject.name,
TimetableNameMode.longName => subject.longName,
TimetableNameMode.alternateName => subject.alternateName,
};
return _collapseWhitespace(name) ?? 'Event';
String _subjectName(String? subjectShort, McTimetableEntry lesson) {
if (subjectShort != null) {
final lookup =
subjects.where((s) => s.shortName == subjectShort).firstOrNull;
final name = switch (settings.timetableNameMode) {
// Backend liefert nur shortName + longName; alternateName fällt auf
// longName zurück.
TimetableNameMode.name => subjectShort,
TimetableNameMode.longName => lookup?.longName ?? subjectShort,
TimetableNameMode.alternateName => lookup?.longName ?? subjectShort,
};
final collapsed = _collapseWhitespace(name);
if (collapsed != null) return collapsed;
}
// Subject leer → Titel aus dem Lesson-Type ableiten. Pausenaufsicht etc.
// sollen nicht generisch als "Event" auftauchen, sondern ihren Zweck samt
// Ort tragen.
return LessonTypeLabel.forEntry(lesson);
}
String _locationLabel(GetTimetableResponseObject lesson) {
String _locationLabel(McTimetableEntry lesson) {
final roomName =
_collapseWhitespace(
rooms.result
.firstWhereOrNull((r) => r.id == lesson.ro.firstOrNull?.id)
?.name,
) ??
'Unbekannt';
_collapseWhitespace(lesson.rooms.firstOrNull) ?? 'Unbekannt';
final teacherName =
_collapseWhitespace(lesson.te.firstOrNull?.longname) ?? 'Unbekannt';
_teacherLabel(lesson.teachers.firstOrNull) ?? 'Unbekannt';
return '$roomName\n$teacherName';
}
/// Backend serves teachers with their full display name ("Stefan Müller"),
/// which doesn't fit into a single calendar tile alongside the room. We
/// reduce it to the last whitespace-separated token (the surname) for the
/// overview; the detail sheet still renders the full name as a subtitle.
static String? _teacherLabel(McTimetableTeacher? teacher) {
if (teacher == null) return null;
final display = _collapseWhitespace(teacher.displayName);
if (display != null && display.isNotEmpty) {
final parts = display.split(' ');
return parts.isEmpty ? display : parts.last;
}
return _collapseWhitespace(teacher.shortName);
}
/// Collapses any line-break or whitespace run to a single space and trims.
/// Returns null when input is null or fully whitespace. Webuntis sometimes
/// returns multi-line room names like "A30\n4" — this normalizes those so
@@ -189,66 +204,67 @@ class TimetableAppointmentFactory {
return cleaned.isEmpty ? null : cleaned;
}
// Pure: returns a new list of fresh objects, does not mutate input.
// (The previous version replaced `previous.endTime` in place, which
// mutated the original lesson object passed in via [input]. Across
// rebuilds those mutated lessons were observed again by the next merge
// pass — extending lessons further or, after the overlap-gap guard was
// added to [_canMerge], even causing the second half of a double lesson
// to be emitted alongside the already-merged block.)
static List<GetTimetableResponseObject> _mergeAdjacentLessons(
List<GetTimetableResponseObject> input, {
// Pure: builds a new list, does not mutate inputs. The previous version
// mutated `previous.endTime` in place which caused merged blocks to grow
// further on subsequent rebuilds when the same lesson objects were observed
// again by the next merge pass.
static List<McTimetableEntry> _mergeAdjacentLessons(
List<McTimetableEntry> input, {
Duration maxGap = const Duration(minutes: 5),
}) {
if (input.isEmpty) return const [];
final sorted = [...input]
..sort(
(a, b) => WebuntisTime.parse(
a.date,
a.startTime,
).compareTo(WebuntisTime.parse(b.date, b.startTime)),
);
..sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
final merged = <GetTimetableResponseObject>[];
final merged = <McTimetableEntry>[];
for (final current in sorted) {
if (merged.isNotEmpty && _canMerge(merged.last, current, maxGap)) {
// `merged.last` is always a copy we created below, so mutating its
// endTime is safe and keeps the next iteration's gap check correct.
merged.last.endTime = current.endTime;
final prev = merged.removeLast();
merged.add(_extendedEnd(prev, current.endTime));
} else {
merged.add(_copyLesson(current));
merged.add(current);
}
}
return merged;
}
static GetTimetableResponseObject _copyLesson(GetTimetableResponseObject l) =>
GetTimetableResponseObject.fromJson(l.toJson());
static McTimetableEntry _extendedEnd(
McTimetableEntry source,
DateTime newEndTime,
) => McTimetableEntry(
id: source.id,
date: source.date,
startTime: source.startTime,
endTime: newEndTime,
subjects: source.subjects,
teachers: source.teachers,
rooms: source.rooms,
classNames: source.classNames,
lessonType: source.lessonType,
status: source.status,
substitutionText: source.substitutionText,
lessonText: source.lessonText,
infoText: source.infoText,
);
static bool _canMerge(
GetTimetableResponseObject a,
GetTimetableResponseObject b,
McTimetableEntry a,
McTimetableEntry b,
Duration maxGap,
) {
final aSubject = a.su.firstOrNull?.id;
final bSubject = b.su.firstOrNull?.id;
if (aSubject == null || bSubject == null || aSubject != bSubject) {
if (a.subjects.firstOrNull != b.subjects.firstOrNull) return false;
if (a.rooms.firstOrNull != b.rooms.firstOrNull) return false;
if (a.teachers.firstOrNull?.shortName !=
b.teachers.firstOrNull?.shortName) {
return false;
}
if (a.ro.firstOrNull?.id != b.ro.firstOrNull?.id) return false;
if (a.te.firstOrNull?.id != b.te.firstOrNull?.id) return false;
if (a.code != b.code) return false;
if (a.status != b.status) return false;
// Merge only sequential lessons (b starts at or after a ends, within the
// tolerance). Without the lower bound, identical-metadata lessons that
// overlap in time would silently collapse into one — and because the
// merge sets `previous.endTime = current.endTime`, an overlapping merge
// can even truncate the earlier lesson.
final gap = WebuntisTime.parse(
b.date,
b.startTime,
).difference(WebuntisTime.parse(a.date, a.endTime));
// overlap in time would silently collapse into one.
final gap = b.startDateTime.difference(a.endDateTime);
return !gap.isNegative && gap <= maxGap;
}
}
@@ -1,16 +0,0 @@
import 'package:intl/intl.dart';
class WebuntisTime {
static final DateFormat _dateFormat = DateFormat('yyyyMMdd');
static DateTime parse(int date, int time) {
final timeString = time.toString().padLeft(4, '0');
return DateTime.parse(
'$date ${timeString.substring(0, 2)}:${timeString.substring(2, 4)}',
);
}
static int formatDate(DateTime date) => int.parse(_dateFormat.format(date));
static String dateKey(DateTime date) => _dateFormat.format(date);
}
@@ -4,7 +4,7 @@ import 'package:syncfusion_flutter_calendar/calendar.dart';
import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart';
import '../data/arbitrary_appointment.dart';
import 'custom_event_sheet.dart';
import 'webuntis_lesson_sheet.dart';
import 'lesson_sheet.dart';
class AppointmentDetailsDispatcher {
static void show(
@@ -16,8 +16,8 @@ class AppointmentDetailsDispatcher {
if (id is! ArbitraryAppointment) return;
id.when(
webuntis: (lesson) =>
WebuntisLessonSheet.show(context, bloc, appointment, lesson),
lesson: (entry) =>
LessonSheet.show(context, bloc, appointment, entry),
custom: (event) => CustomEventSheet.show(context, event),
);
}
@@ -0,0 +1,302 @@
import 'package:flutter/material.dart';
import 'package:syncfusion_flutter_calendar/calendar.dart';
import '../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
import '../../../../extensions/date_time.dart';
import '../../../../extensions/text.dart';
import '../../../../routing/app_routes.dart';
import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart';
import '../../../../widget/debug/debug_tile.dart';
import '../../../../widget/details_bottom_sheet.dart';
import '../data/lesson_type_label.dart';
class LessonSheet {
static void show(
BuildContext context,
TimetableBloc bloc,
Appointment appointment,
McTimetableEntry lesson,
) {
final state = bloc.state.data;
if (state == null) return;
final subjectShort = lesson.subjects.firstOrNull;
final headerLong = subjectShort == null
? null
: state.subjects?.result
.where((s) => s.shortName == subjectShort)
.firstOrNull
?.longName;
// Bei Stunden ohne Fach (Pausenaufsicht etc.) den Lesson-Type-Titel
// einsetzen — sonst stünde im Header nur ein generisches "?".
final headerTitle = subjectShort != null
? firstNonEmpty([subjectShort, headerLong, '?'])
: LessonTypeLabel.forEntry(lesson);
final headerLongName =
(headerLong != null && headerLong.isNotEmpty && headerLong != headerTitle)
? headerLong
: '';
final timeRange = appointment.startTime.timeRangeTo(appointment.endTime);
showDetailsBottomSheet(
context,
header: ListTile(
leading: Icon(_iconForStatus(lesson.status), size: 32),
title: Text(
'${_statusPrefix(lesson.status)}$headerTitle',
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(
headerLongName.isNotEmpty ? '$timeRange\n$headerLongName' : timeRange,
),
isThreeLine: headerLongName.isNotEmpty,
),
children: (_) => <Widget>[
ListTile(
leading: const Icon(Icons.notifications_active),
title: Text('Status: ${_statusLabel(lesson.status)}'),
),
if (lesson.subjects.length > 1)
_listTile(
icon: Icons.book_outlined,
label: 'Fächer',
entries: lesson.subjects
.map(
(s) => _line(s, longname: _subjectLongName(state.subjects, s)),
)
.toList(),
),
_roomTile(context, lesson),
_teacherTile(lesson),
if (lesson.classNames.isNotEmpty)
_listTile(
icon: Icons.people,
label: lesson.classNames.length == 1 ? 'Klasse' : 'Klassen',
entries: lesson.classNames.map(_line).toList(),
),
..._optionalTextTiles(lesson),
DebugTile(context).jsonData(lesson.toJson()),
],
);
}
static Widget _roomTile(BuildContext context, McTimetableEntry lesson) {
final trailing = IconButton(
icon: const Icon(Icons.house_outlined),
onPressed: () => AppRoutes.openRoomplan(context),
);
if (lesson.rooms.isEmpty) {
return ListTile(
leading: const Icon(Icons.room),
title: const Text('Raum: ?'),
trailing: trailing,
);
}
final entries = lesson.rooms
.map((name) => (main: _line(name), sub: null as String?))
.toList();
return _listTileWithSubs(
icon: Icons.room,
label: lesson.rooms.length == 1 ? 'Raum' : 'Räume',
entries: entries,
trailing: trailing,
);
}
static Widget _teacherTile(McTimetableEntry lesson) {
if (lesson.teachers.isEmpty) {
return const ListTile(
leading: Icon(Icons.person),
title: Text('Lehrkraft: ?'),
);
}
final entries = lesson.teachers.map((t) {
final shortName = t.shortName.isEmpty ? '?' : t.shortName;
final longName = t.displayName.trim();
final orgShort = (t.originalShortName ?? '').trim();
final orgLong = (t.originalDisplayName ?? '').trim();
final subLines = <String>[];
if (longName.isNotEmpty && longName != shortName) {
subLines.add(longName);
}
if (orgShort.isNotEmpty) {
final label = orgLong.isEmpty || orgLong == orgShort
? orgShort
: '$orgShort · $orgLong';
subLines.add('ehemals $label');
}
return (
main: shortName,
sub: subLines.isEmpty ? null : subLines.join('\n'),
);
}).toList();
return _listTileWithSubs(
icon: Icons.person,
label: lesson.teachers.length == 1 ? 'Lehrkraft' : 'Lehrkräfte',
entries: entries,
);
}
static Widget _listTileWithSubs({
required IconData icon,
required String label,
required List<({String main, String? sub})> entries,
Widget? trailing,
}) {
if (entries.length == 1) {
final e = entries.first;
return ListTile(
leading: Icon(icon),
title: Text('$label: ${e.main}'),
subtitle: e.sub != null ? Text(e.sub!) : null,
trailing: trailing,
);
}
return ListTile(
leading: Icon(icon),
title: Text(label),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: entries
.expand<Widget>(
(e) => [
Text(e.main),
if (e.sub != null)
Padding(
padding: const EdgeInsets.only(left: 12),
child: Text(e.sub!),
),
],
)
.toList(),
),
trailing: trailing,
);
}
static Widget _listTile({
required IconData icon,
required String label,
required List<String> entries,
Widget? trailing,
}) {
if (entries.length == 1) {
return ListTile(
leading: Icon(icon),
title: Text('$label: ${entries.first}'),
trailing: trailing,
);
}
return ListTile(
leading: Icon(icon),
title: Text(label),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: entries.map<Widget>(Text.new).toList(),
),
trailing: trailing,
);
}
static List<Widget> _optionalTextTiles(McTimetableEntry lesson) {
return <Widget?>[
_textTile(Icons.info_outline, 'Info', lesson.infoText),
_textTile(Icons.swap_horiz, 'Vertretungstext', lesson.substitutionText),
_textTile(Icons.subject, 'Stundentext', lesson.lessonText),
_textTile(
Icons.category_outlined,
'Stundentyp',
_lessonTypeLabel(lesson.lessonType),
),
].whereType<Widget>().toList();
}
/// Marianum-Connect liefert den Stundentyp immer (Default `LESSON`). Den
/// Standard blenden wir aus — sonst stünde unter jeder regulären Stunde
/// derselbe Eintrag. Sonderfälle bekommen einen deutschen Klartext.
static String? _lessonTypeLabel(String type) {
switch (type) {
case 'LESSON':
return null;
case 'OFFICE_HOUR':
return 'Sprechstunde';
case 'STANDBY':
return 'Bereitschaft';
case 'BREAK_SUPERVISION':
return 'Pausenaufsicht';
case 'EXAM':
return 'Prüfung';
default:
return type;
}
}
static Widget? _textTile(IconData icon, String label, String? value) {
final text = (value ?? '').trim();
if (text.isEmpty || text == '-') return null;
return ListTile(
leading: Icon(icon),
title: Text(label),
subtitle: Text(text),
);
}
static String _line(String name, {String? longname, String? extra}) {
final parts = <String>[if (name.isNotEmpty) name else '?'];
final ln = (longname ?? '').trim();
if (ln.isNotEmpty && ln != name) parts.add('($ln)');
final ex = (extra ?? '').trim();
if (ex.isNotEmpty) parts.add('· $ex');
return parts.join(' ');
}
static String? _subjectLongName(dynamic subjects, String shortName) {
if (subjects == null) return null;
final list = subjects.result as Iterable<dynamic>;
for (final s in list) {
if (s.shortName == shortName) return s.longName as String?;
}
return null;
}
static IconData _iconForStatus(String status) {
switch (status) {
case 'CANCELLED':
return Icons.event_busy_outlined;
case 'IRREGULAR':
return Icons.swap_horiz;
default:
return Icons.school_outlined;
}
}
static String _statusLabel(String status) {
switch (status) {
case 'CANCELLED':
return 'Entfällt';
case 'IRREGULAR':
return 'Geändert';
default:
return 'Regulär';
}
}
static String _statusPrefix(String status) {
switch (status) {
case 'CANCELLED':
return 'Entfällt: ';
case 'IRREGULAR':
return 'Änderung: ';
default:
return '';
}
}
}
@@ -1,244 +0,0 @@
import 'package:flutter/material.dart';
import 'package:syncfusion_flutter_calendar/calendar.dart';
import '../../../../api/webuntis/queries/get_timetable/get_timetable_response.dart';
import '../../../../api/webuntis/services/lesson_resolver.dart';
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';
class WebuntisLessonSheet {
static void show(
BuildContext context,
TimetableBloc bloc,
Appointment appointment,
GetTimetableResponseObject lesson,
) {
final state = bloc.state.data;
if (state == null) return;
final headerSubject = LessonResolver.resolveSubject(
state,
lesson.su.firstOrNull?.id,
);
final headerTitle = firstNonEmpty([
headerSubject.alternateName,
headerSubject.name,
headerSubject.longName,
'?',
]);
final headerLongName =
headerSubject.longName.isNotEmpty &&
headerSubject.longName != headerTitle
? headerSubject.longName
: '';
final timeRange = appointment.startTime.timeRangeTo(appointment.endTime);
showDetailsBottomSheet(
context,
header: ListTile(
leading: Icon(LessonFormatter.iconForCode(lesson.code), size: 32),
title: Text(
'${LessonFormatter.codePrefix(lesson.code)}$headerTitle',
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(
headerLongName.isNotEmpty ? '$timeRange\n$headerLongName' : timeRange,
),
isThreeLine: headerLongName.isNotEmpty,
),
children: (_) => <Widget>[
ListTile(
leading: const Icon(Icons.notifications_active),
title: Text('Status: ${LessonFormatter.statusLabel(lesson.code)}'),
),
if (lesson.su.length > 1)
_listTile(
icon: Icons.book_outlined,
label: 'Fächer',
entries: lesson.su.map((s) {
final resolved = LessonResolver.resolveSubject(state, s.id);
return LessonFormatter.formatLine(
firstNonEmpty([resolved.name, s.name, '?']),
longname: firstNonEmpty([resolved.longName, s.longname, '']),
);
}).toList(),
),
_roomTile(context, state, lesson),
_teacherTile(lesson),
if ((lesson.activityType ?? '').trim().isNotEmpty)
ListTile(
leading: const Icon(Icons.abc),
title: Text('Typ: ${lesson.activityType}'),
),
if (lesson.kl.isNotEmpty)
_listTile(
icon: Icons.people,
label: lesson.kl.length == 1 ? 'Klasse' : 'Klassen',
entries: lesson.kl
.map(
(k) => LessonFormatter.formatLine(
k.name.isNotEmpty ? k.name : '?',
longname: k.longname,
),
)
.toList(),
),
..._optionalTextTiles(lesson),
DebugTile(context).jsonData(lesson.toJson()),
],
);
}
static Widget _roomTile(
BuildContext context,
TimetableState state,
GetTimetableResponseObject lesson,
) {
final trailing = IconButton(
icon: const Icon(Icons.house_outlined),
onPressed: () => AppRoutes.openRoomplan(context),
);
if (lesson.ro.isEmpty) {
return ListTile(
leading: const Icon(Icons.room),
title: const Text('Raum: ?'),
trailing: trailing,
);
}
final entries = lesson.ro.map((r) {
final resolved = LessonResolver.resolveRoom(state, r.id);
final name = firstNonEmpty([resolved.name, r.name, '?']);
final longname = firstNonEmpty([resolved.longName, r.longname, '']);
final building = resolved.building.trim();
final main = LessonFormatter.formatLine(
name,
extra: (building.isNotEmpty && building != '?') ? building : null,
);
final sub = (longname.isNotEmpty && longname != name) ? longname : null;
return (main: main, sub: sub);
}).toList();
return _listTileWithSubs(
icon: Icons.room,
label: lesson.ro.length == 1 ? 'Raum' : 'Räume',
entries: entries,
trailing: trailing,
);
}
static Widget _teacherTile(GetTimetableResponseObject lesson) {
if (lesson.te.isEmpty) {
return const ListTile(
leading: Icon(Icons.person),
title: Text('Lehrkraft: ?'),
);
}
final entries = lesson.te.map((t) {
final main = LessonFormatter.formatLine(
t.name.isNotEmpty ? t.name : '?',
longname: t.longname,
);
final orgname = (t.orgname ?? '').trim();
return (main: main, sub: orgname.isEmpty ? null : 'ehemals $orgname');
}).toList();
return _listTileWithSubs(
icon: Icons.person,
label: lesson.te.length == 1 ? 'Lehrkraft' : 'Lehrkräfte',
entries: entries,
);
}
static Widget _listTileWithSubs({
required IconData icon,
required String label,
required List<({String main, String? sub})> entries,
Widget? trailing,
}) {
if (entries.length == 1) {
final e = entries.first;
return ListTile(
leading: Icon(icon),
title: Text('$label: ${e.main}'),
subtitle: e.sub != null ? Text(e.sub!) : null,
trailing: trailing,
);
}
return ListTile(
leading: Icon(icon),
title: Text(label),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: entries
.expand<Widget>(
(e) => [
Text(e.main),
if (e.sub != null)
Padding(
padding: const EdgeInsets.only(left: 12),
child: Text(e.sub!),
),
],
)
.toList(),
),
trailing: trailing,
);
}
static Widget _listTile({
required IconData icon,
required String label,
required List<String> entries,
Widget? trailing,
}) {
if (entries.length == 1) {
return ListTile(
leading: Icon(icon),
title: Text('$label: ${entries.first}'),
trailing: trailing,
);
}
return ListTile(
leading: Icon(icon),
title: Text(label),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: entries.map<Widget>(Text.new).toList(),
),
trailing: trailing,
);
}
static List<Widget> _optionalTextTiles(GetTimetableResponseObject lesson) {
return <Widget?>[
_textTile(Icons.info_outline, 'Info', lesson.info),
_textTile(Icons.swap_horiz, 'Vertretungstext', lesson.substText),
_textTile(Icons.subject, 'Stundentext', lesson.lstext),
_textTile(Icons.category_outlined, 'Stundentyp', lesson.lstype),
_textTile(Icons.flag_outlined, 'Statusmerkmale', lesson.statflags),
_textTile(Icons.school_outlined, 'Lerngruppe', lesson.sg),
_textTile(Icons.bookmark_outline, 'Buchungshinweis', lesson.bkRemark),
_textTile(Icons.notes, 'Buchungstext', lesson.bkText),
].whereType<Widget>().toList();
}
static Widget? _textTile(IconData icon, String label, String? value) {
final text = (value ?? '').trim();
if (text.isEmpty || text == '-') return null;
return ListTile(
leading: Icon(icon),
title: Text(label),
subtitle: Text(text),
);
}
}
+12 -7
View File
@@ -13,7 +13,6 @@ 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 'data/webuntis_time.dart';
import 'details/appointment_details_dispatcher.dart';
import 'widgets/custom_workweek_calendar.dart';
import 'widgets/special_regions_builder.dart';
@@ -70,8 +69,7 @@ class _TimetableState extends State<Timetable> {
return _cachedAppointments = TimetableAppointmentFactory(
lessons: state.getAllKnownLessons().toList(),
customEvents: state.customEvents?.events ?? const [],
rooms: state.rooms!,
subjects: state.subjects!,
subjects: state.subjects?.result ?? const [],
settings: timetableSettings,
now: DateTime.now(),
).build();
@@ -79,7 +77,7 @@ class _TimetableState extends State<Timetable> {
bool _isCrossedOut(Appointment appointment) {
final id = appointment.id;
if (id is WebuntisAppointment) return id.lesson.code == 'cancelled';
if (id is LessonAppointment) return id.entry.status == 'CANCELLED';
return false;
}
@@ -123,6 +121,13 @@ class _TimetableState extends State<Timetable> {
],
),
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.
isReady: (state) => state.hasReferenceData,
child: (state, _) => _calendar(state, bloc),
),
);
@@ -192,12 +197,12 @@ class _TimetableState extends State<Timetable> {
/// `_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?.result;
final year = state.schoolyear;
final DateTime baseMin;
final DateTime baseMax;
if (year != null) {
baseMin = WebuntisTime.parse(year.startDate, 0);
baseMax = WebuntisTime.parse(year.endDate, 0);
baseMin = year.startDate;
baseMax = year.endDate;
} else {
final now = DateTime.now();
baseMin = now.subtractDays(14);
@@ -38,7 +38,10 @@ class _OutsideHoursStrip extends StatelessWidget {
(maxChipsPerDay > 1 ? (maxChipsPerDay - 1) * kOutsideChipSpacing : 0);
return Container(
color: theme.colorScheme.surfaceContainerLowest,
// Scaffold-Background, damit die Ganztagestermine-Leiste optisch nahtlos
// an Header und Stundenplan-Hintergrund anschließt; surfaceContainerLowest
// ist in M3-Light reinweiß und sticht gegen die getönte Seed-Surface ab.
color: theme.scaffoldBackgroundColor,
padding: const EdgeInsets.symmetric(
vertical: kOutsideStripVerticalPadding,
),
@@ -1,15 +1,14 @@
import 'package:flutter/material.dart';
import 'package:syncfusion_flutter_calendar/calendar.dart';
import '../../../../api/webuntis/queries/get_holidays/get_holidays_response.dart';
import '../../../../api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays_response.dart';
import '../../../../extensions/date_time.dart';
import '../data/calendar_layout.dart';
import '../data/lesson_period_schedule.dart';
import '../data/webuntis_time.dart';
import 'time_region_tile.dart';
class SpecialRegionsBuilder {
final GetHolidaysResponse holidays;
final TimetableGetHolidaysResponse holidays;
final LessonPeriodSchedule schedule;
final ColorScheme colorScheme;
final Color disabledColor;
@@ -59,14 +58,22 @@ class SpecialRegionsBuilder {
static String _dayKey(DateTime d) => '${d.year}-${d.month}-${d.day}';
Iterable<TimeRegion> _buildHolidayRegions() {
// Multiple Webuntis holiday entries can cover the same day (e.g. a
// public holiday falling inside a vacation period). Collapse them
// per-day so we emit exactly one TimeRegion per day and the
// overlapping labels don't render on top of each other.
// Multiple holiday entries can cover the same day (e.g. a public holiday
// falling inside a vacation period). Collapse them per-day so we emit
// exactly one TimeRegion per day and the overlapping labels don't render
// on top of each other.
final byDay = <String, _HolidayDay>{};
for (final holiday in holidays.result) {
final startDay = WebuntisTime.parse(holiday.startDate, 0);
final endDay = WebuntisTime.parse(holiday.endDate, 0);
final startDay = DateTime(
holiday.startDate.year,
holiday.startDate.month,
holiday.startDate.day,
);
final endDay = DateTime(
holiday.endDate.year,
holiday.endDate.month,
holiday.endDate.day,
);
// Webuntis treats endDate inclusively (last day of the break) — the
// `+ 1` covers single-day public holidays (where startDate == endDate)
// and the final day of a multi-day vacation, both of which would
@@ -76,7 +83,7 @@ class SpecialRegionsBuilder {
final day = startDay.addDays(i);
final key = _dayKey(day);
byDay.putIfAbsent(key, () => _HolidayDay(day, [])).names.add(
holiday.name,
holiday.shortName,
);
}
}