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