added base homescreen-widget setup, working on Android, iOS in progress

This commit is contained in:
2026-05-09 18:01:05 +02:00
parent 0ff5eb7bc9
commit 00664c66a8
66 changed files with 5600 additions and 4 deletions
+472
View File
@@ -0,0 +1,472 @@
import 'dart:developer';
import 'package:rrule/rrule.dart';
import '../api/mhsl/custom_timetable_event/custom_timetable_event.dart';
import '../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart';
import '../api/webuntis/queries/get_holidays/get_holidays_response.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_timegrid_units/get_timegrid_units_response.dart';
import '../api/webuntis/queries/get_timetable/get_timetable_response.dart';
import '../view/pages/timetable/data/lesson_period_schedule.dart';
import '../view/pages/timetable/data/lesson_status.dart';
import '../view/pages/timetable/data/webuntis_time.dart';
import 'widget_data.dart';
class WidgetDataMapper {
/// After 17:00 the user's question shifts from "what's left today" to
/// "what's tomorrow", so the day-widget rolls forward.
static const int _dayWidgetCutoffHour = 17;
static const _weekend = {DateTime.saturday, DateTime.sunday};
static DateTime resolveDayAnchor(DateTime now) {
var candidate = DateTime(now.year, now.month, now.day);
final shiftToTomorrow =
now.hour >= _dayWidgetCutoffHour || _weekend.contains(now.weekday);
if (shiftToTomorrow) {
candidate = candidate.add(const Duration(days: 1));
}
while (_weekend.contains(candidate.weekday)) {
candidate = candidate.add(const Duration(days: 1));
}
return candidate;
}
static DateTime resolveWeekAnchor(DateTime now) {
final anchor = resolveDayAnchor(now);
final monday = anchor.subtract(Duration(days: anchor.weekday - 1));
return DateTime(monday.year, monday.month, monday.day);
}
static WidgetTimetableData buildDayData({
required DateTime now,
required Iterable<GetTimetableResponseObject> lessons,
required GetSubjectsResponse? subjects,
required GetRoomsResponse? rooms,
required GetHolidaysResponse? holidays,
GetTimegridUnitsResponse? timegrid,
GetCustomTimetableEventResponse? customEvents,
bool connectDoubleLessons = true,
}) {
final anchor = resolveDayAnchor(now);
final holiday = _findHoliday(anchor, holidays);
final dayStart = anchor;
final dayEnd = anchor.add(const Duration(days: 1));
final dayLessons = lessons.where((l) => _onSameDay(l, anchor)).toList();
final source = connectDoubleLessons
? _mergeAdjacentLessons(dayLessons)
: dayLessons;
final mapped = <WidgetLesson>[
...source.map((l) => _mapLesson(l, now, subjects, rooms)),
..._expandCustomEvents(customEvents, dayStart, dayEnd),
]..sort((a, b) => a.start.compareTo(b.start));
return WidgetTimetableData(
fetchedAt: now,
anchorDate: anchor,
lessons: _resolveCollisions(mapped),
periods: _resolvePeriods(timegrid),
isHoliday: holiday != null,
holidayName: holiday?.longName,
);
}
static WidgetTimetableData buildWeekData({
required DateTime now,
required Iterable<GetTimetableResponseObject> lessons,
required GetSubjectsResponse? subjects,
required GetRoomsResponse? rooms,
required GetHolidaysResponse? holidays,
GetTimegridUnitsResponse? timegrid,
GetCustomTimetableEventResponse? customEvents,
bool connectDoubleLessons = true,
}) {
final anchor = resolveWeekAnchor(now);
final endExclusive = anchor.add(const Duration(days: 5));
final weekLessons = lessons.where((l) {
final dt = WebuntisTime.parse(l.date, l.startTime);
return !dt.isBefore(anchor) && dt.isBefore(endExclusive);
}).toList();
// Per-day merge: otherwise a 4th-period lesson on Mon would collapse with
// a 1st-period lesson on Tue if subject/teacher match.
final source = connectDoubleLessons
? _mergePerDay(weekLessons)
: weekLessons;
final mapped = <WidgetLesson>[
...source.map((l) => _mapLesson(l, now, subjects, rooms)),
..._expandCustomEvents(customEvents, anchor, endExclusive),
]..sort((a, b) => a.start.compareTo(b.start));
return WidgetTimetableData(
fetchedAt: now,
anchorDate: anchor,
lessons: _resolveCollisions(mapped),
periods: _resolvePeriods(timegrid),
);
}
/// cancelled (0) < event (1) < regular (2) — events replace cancelled
/// lessons but lose to real ones, leaving a `+1` hint on the survivor.
static int _priority(WidgetLessonStatus status) => switch (status) {
WidgetLessonStatus.cancelled => 0,
WidgetLessonStatus.event => 1,
_ => 2,
};
static List<WidgetLesson> _resolveCollisions(List<WidgetLesson> lessons) {
if (lessons.length <= 1) return lessons;
bool overlaps(WidgetLesson l, WidgetLesson other) =>
l != other && l.start.isBefore(other.end) && l.end.isAfter(other.start);
// Index-based: a long event covering several regulars must bump *every*
// covered lesson, not just the first overlap.
final dropped = List<bool>.filled(lessons.length, false);
final bumps = List<int>.filled(lessons.length, 0);
for (var i = 0; i < lessons.length; i++) {
final l = lessons[i];
final myPrio = _priority(l.status);
final overrideIdxs = <int>[];
for (var j = 0; j < lessons.length; j++) {
if (i == j) continue;
if (_priority(lessons[j].status) <= myPrio) continue;
if (!overlaps(l, lessons[j])) continue;
overrideIdxs.add(j);
}
if (overrideIdxs.isNotEmpty) {
dropped[i] = true;
if (l.status == WidgetLessonStatus.event) {
for (final idx in overrideIdxs) {
bumps[idx] += 1;
}
}
}
}
final filtered = <WidgetLesson>[];
for (var i = 0; i < lessons.length; i++) {
if (dropped[i]) continue;
final l = lessons[i];
filtered.add(
bumps[i] > 0
? l.copyWith(siblingCount: l.siblingCount + bumps[i])
: l,
);
}
if (filtered.length <= 1) return filtered;
final groups = <String, List<WidgetLesson>>{};
for (final l in filtered) {
final key =
'${l.start.year}-${l.start.month}-${l.start.day}-${l.start.hour}-${l.start.minute}';
groups.putIfAbsent(key, () => []).add(l);
}
final result = <WidgetLesson>[];
for (final group in groups.values) {
if (group.length == 1) {
result.add(group.first);
continue;
}
final active = group
.where((l) => l.status != WidgetLessonStatus.cancelled)
.toList();
if (active.isEmpty) {
result.addAll(group);
continue;
}
active.sort((a, b) => a.subjectShort.compareTo(b.subjectShort));
// Additive — preserves the event-bump from the priority pass, otherwise
// a slot with another regular lesson AND a hidden event would show +1
// instead of +2.
final keeper = active.first;
result.add(
keeper.copyWith(
siblingCount: keeper.siblingCount + active.length - 1,
),
);
}
return result..sort((a, b) => a.start.compareTo(b.start));
}
/// Gaps below this collapse to zero on the virtual axis so 45-min slots
/// stack flush; bigger gaps survive as visible Pause-blocks.
static const int _smallBreakThresholdMinutes = 5;
static List<WidgetPeriod> _resolvePeriods(
GetTimegridUnitsResponse? timegrid,
) {
final schedule =
(timegrid != null ? LessonPeriodSchedule.fromApi(timegrid) : null) ??
LessonPeriodSchedule.fallback();
final raw = schedule.periods
.map(
(p) => (
name: p.name,
start: p.start.hour * 60 + p.start.minute,
end: p.end.hour * 60 + p.end.minute,
),
)
.toList()
..sort((a, b) => a.start.compareTo(b.start));
final result = <WidgetPeriod>[];
var virtualOffset = 0;
int? prevEnd;
for (final p in raw) {
if (prevEnd != null) {
final gap = p.start - prevEnd;
if (gap > _smallBreakThresholdMinutes) virtualOffset += gap;
}
final duration = p.end - p.start;
result.add(
WidgetPeriod(
name: p.name,
startMinutes: p.start,
endMinutes: p.end,
virtualStartMinutes: virtualOffset,
virtualEndMinutes: virtualOffset + duration,
),
);
virtualOffset += duration;
prevEnd = p.end;
}
return result;
}
static List<GetTimetableResponseObject> _mergePerDay(
List<GetTimetableResponseObject> lessons,
) {
final byDay = <int, List<GetTimetableResponseObject>>{};
for (final l in lessons) {
byDay.putIfAbsent(l.date, () => []).add(l);
}
return [for (final group in byDay.values) ..._mergeAdjacentLessons(group)];
}
/// Mirrors `TimetableAppointmentFactory._mergeAdjacentLessons` so the
/// widget shows the same merged blocks the in-app calendar does.
static List<GetTimetableResponseObject> _mergeAdjacentLessons(
List<GetTimetableResponseObject> 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)),
);
final merged = <GetTimetableResponseObject>[];
for (final current in sorted) {
if (merged.isNotEmpty && _canMerge(merged.last, current, maxGap)) {
merged.last.endTime = current.endTime;
} else {
merged.add(GetTimetableResponseObject.fromJson(current.toJson()));
}
}
return merged;
}
static bool _canMerge(
GetTimetableResponseObject a,
GetTimetableResponseObject b,
Duration maxGap,
) {
final aSubject = a.su.firstOrNull?.id;
final bSubject = b.su.firstOrNull?.id;
if (aSubject == null || bSubject == null || aSubject != bSubject) {
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;
final gap = WebuntisTime.parse(
b.date,
b.startTime,
).difference(WebuntisTime.parse(a.date, a.endTime));
return !gap.isNegative && gap <= maxGap;
}
static WidgetLesson _mapLesson(
GetTimetableResponseObject lesson,
DateTime now,
GetSubjectsResponse? subjects,
GetRoomsResponse? rooms,
) {
final start = WebuntisTime.parse(lesson.date, lesson.startTime);
final end = WebuntisTime.parse(lesson.date, lesson.endTime);
final status = _mapStatus(
LessonStatusClassifier.classify(lesson, start, end, now),
);
final subject = lesson.su.firstOrNull;
// Webuntis sometimes ships subject-less entries (Wandertag etc.). Fall
// back to "Event" so the tile isn't just a dash.
final rawSubjectName = subject?.name.trim() ?? '';
final subjectShort = rawSubjectName.isEmpty ? 'Event' : rawSubjectName;
String? subjectLong;
if (subjects != null && subject != null) {
final found = subjects.result.where((s) => s.id == subject.id).firstOrNull;
subjectLong = found?.longName;
}
subjectLong ??= subject?.longname;
final room = lesson.ro.firstOrNull;
var roomName = room?.name;
if (rooms != null && room != null) {
final resolved =
rooms.result.where((r) => r.id == room.id).firstOrNull?.name;
roomName = resolved ?? roomName;
}
final teacher = lesson.te.firstOrNull;
final teacherName = teacher?.id == 0 ? null : teacher?.name;
final originalTeacher = teacher?.orgname;
return WidgetLesson(
start: start,
end: end,
subjectShort: subjectShort,
subjectLong: subjectLong,
room: roomName,
teacher: teacherName,
originalTeacher: originalTeacher,
status: status,
);
}
static WidgetLessonStatus _mapStatus(LessonStatus status) {
switch (status) {
case LessonStatus.cancelled:
return WidgetLessonStatus.cancelled;
case LessonStatus.event:
return WidgetLessonStatus.event;
case LessonStatus.irregular:
return WidgetLessonStatus.irregular;
case LessonStatus.teacherChanged:
return WidgetLessonStatus.teacherChanged;
case LessonStatus.past:
return WidgetLessonStatus.past;
case LessonStatus.ongoing:
return WidgetLessonStatus.ongoing;
case LessonStatus.regular:
return WidgetLessonStatus.regular;
}
}
static bool _onSameDay(GetTimetableResponseObject lesson, DateTime day) {
final dt = WebuntisTime.parse(lesson.date, lesson.startTime);
return dt.year == day.year && dt.month == day.month && dt.day == day.day;
}
static GetHolidaysResponseObject? _findHoliday(
DateTime day,
GetHolidaysResponse? holidays,
) {
if (holidays == null) return null;
final asInt = WebuntisTime.formatDate(day);
for (final h in holidays.result) {
if (asInt >= h.startDate && asInt <= h.endDate) return h;
}
return null;
}
static Iterable<WidgetLesson> _expandCustomEvents(
GetCustomTimetableEventResponse? customEvents,
DateTime rangeStart,
DateTime rangeEndExclusive,
) sync* {
if (customEvents == null) return;
final rangeStartUtc = rangeStart.toUtc();
final rangeEndUtc = rangeEndExclusive.toUtc();
for (final event in customEvents.events) {
yield* _expandSingleEvent(event, rangeStartUtc, rangeEndUtc);
}
}
static Iterable<WidgetLesson> _expandSingleEvent(
CustomTimetableEvent event,
DateTime rangeStartUtc,
DateTime rangeEndUtc,
) sync* {
final rule = event.rrule;
final duration = event.endDate.difference(event.startDate);
if (rule.isEmpty) {
final startUtc = event.startDate.toUtc();
if (startUtc.isBefore(rangeStartUtc) ||
!startUtc.isBefore(rangeEndUtc)) {
return;
}
yield* _customEventToWidgetLessons(event, event.startDate, duration);
return;
}
try {
final parsed = RecurrenceRule.fromString(rule);
final anchorUtc = event.startDate.toUtc();
for (final occUtc in parsed.getInstances(start: anchorUtc)) {
if (!occUtc.isBefore(rangeEndUtc)) break;
if (occUtc.isBefore(rangeStartUtc)) continue;
final occLocal = occUtc.toLocal();
final occStart = DateTime(
occLocal.year,
occLocal.month,
occLocal.day,
event.startDate.hour,
event.startDate.minute,
);
yield* _customEventToWidgetLessons(event, occStart, duration);
}
} on Exception catch (e) {
log('Widget mapper: invalid rrule "$rule" on event ${event.id}: $e');
}
}
/// Splits multi-day events into one block per local calendar day, so each
/// affected day on the week-widget shows the event. All-day events
/// (start = end = midnight) collapse to a single 00:0023:59 block.
static Iterable<WidgetLesson> _customEventToWidgetLessons(
CustomTimetableEvent event,
DateTime occurrenceStart,
Duration duration,
) sync* {
final title = event.title.trim();
WidgetLesson buildBlock(DateTime start, DateTime end) => WidgetLesson(
start: start,
end: end,
subjectShort: title.isEmpty ? 'Termin' : title,
subjectLong: title.isEmpty ? null : title,
status: WidgetLessonStatus.event,
customColor: event.color,
);
final isAllDay = duration == Duration.zero && _isMidnight(event.startDate);
if (isAllDay) {
yield buildBlock(
occurrenceStart,
DateTime(
occurrenceStart.year,
occurrenceStart.month,
occurrenceStart.day,
23,
59,
),
);
return;
}
final actualEnd = occurrenceStart.add(duration);
var segmentStart = occurrenceStart;
while (segmentStart.isBefore(actualEnd)) {
final nextMidnight = DateTime(
segmentStart.year,
segmentStart.month,
segmentStart.day,
).add(const Duration(days: 1));
final segmentEnd = actualEnd.isBefore(nextMidnight)
? actualEnd
: nextMidnight.subtract(const Duration(minutes: 1));
yield buildBlock(segmentStart, segmentEnd);
segmentStart = nextMidnight;
}
}
static bool _isMidnight(DateTime d) =>
d.hour == 0 && d.minute == 0 && d.second == 0;
}