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:
@@ -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 Mon–Fri, 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user