fixed overwrite on internal copy, DST drift in widget anchors, inline errors without controller and removed dead room lookup

This commit is contained in:
2026-09-27 17:37:31 +02:00
parent ee1240f71a
commit 2664378b17
13 changed files with 86 additions and 152 deletions
+3 -11
View File
@@ -9,14 +9,13 @@ import '../api/marianumconnect/marianumconnect_endpoint.dart';
import '../api/marianumconnect/queries/timetable_custom_events/timetable_custom_events_get.dart'; import '../api/marianumconnect/queries/timetable_custom_events/timetable_custom_events_get.dart';
import '../api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays.dart'; import '../api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays.dart';
import '../api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays_response.dart'; import '../api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays_response.dart';
import '../api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms.dart';
import '../api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms_response.dart';
import '../api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects.dart'; import '../api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects.dart';
import '../api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.dart'; import '../api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.dart';
import '../api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid.dart'; import '../api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid.dart';
import '../api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid_response.dart'; import '../api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid_response.dart';
import '../api/marianumconnect/queries/timetable_get_week/timetable_get_week.dart'; import '../api/marianumconnect/queries/timetable_get_week/timetable_get_week.dart';
import '../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart'; import '../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart';
import '../extensions/date_time.dart';
import '../session/session_manager.dart'; import '../session/session_manager.dart';
import '../widget_data/widget_data_mapper.dart'; import '../widget_data/widget_data_mapper.dart';
import '../widget_data/widget_publisher.dart'; import '../widget_data/widget_publisher.dart';
@@ -151,9 +150,7 @@ Future<void> _refresh() async {
// Fetch window matches the week payload's window so the widget can roll // Fetch window matches the week payload's window so the widget can roll
// forward into next week's lessons without fresh data. // forward into next week's lessons without fresh data.
final weekStart = WidgetDataMapper.startOfCalendarWeek(now); final weekStart = WidgetDataMapper.startOfCalendarWeek(now);
final weekEndExclusive = weekStart.add( final weekEndExclusive = weekStart.addDays(WidgetDataMapper.weekWindowDays);
const Duration(days: WidgetDataMapper.weekWindowDays),
);
// All six requests are independent — run them concurrently so the total // All six requests are independent — run them concurrently so the total
// latency is the slowest request, not the sum (matters for the push path's // latency is the slowest request, not the sum (matters for the push path's
@@ -161,14 +158,11 @@ Future<void> _refresh() async {
// mapper rather than aborting the whole refresh. // mapper rather than aborting the whole refresh.
final timetableFuture = TimetableGetWeek().run( final timetableFuture = TimetableGetWeek().run(
from: weekStart, from: weekStart,
until: weekEndExclusive.subtract(const Duration(days: 1)), until: weekEndExclusive.subtractDays(1),
); );
final subjectsFuture = _runOrNull<TimetableGetSubjectsResponse>( final subjectsFuture = _runOrNull<TimetableGetSubjectsResponse>(
() => TimetableGetSubjects().run(), () => TimetableGetSubjects().run(),
); );
final roomsFuture = _runOrNull<TimetableGetRoomsResponse>(
() => TimetableGetRooms().run(),
);
final holidaysFuture = _runOrNull<TimetableGetHolidaysResponse>( final holidaysFuture = _runOrNull<TimetableGetHolidaysResponse>(
() => TimetableGetHolidays().run(), () => TimetableGetHolidays().run(),
); );
@@ -180,7 +174,6 @@ Future<void> _refresh() async {
); );
final timetable = await timetableFuture; final timetable = await timetableFuture;
final subjects = await subjectsFuture; final subjects = await subjectsFuture;
final rooms = await roomsFuture;
final holidays = await holidaysFuture; final holidays = await holidaysFuture;
final timegrid = await timegridFuture; final timegrid = await timegridFuture;
final customEvents = await customEventsFuture; final customEvents = await customEventsFuture;
@@ -193,7 +186,6 @@ Future<void> _refresh() async {
now: now, now: now,
lessons: timetable.entries, lessons: timetable.entries,
subjects: subjects, subjects: subjects,
rooms: rooms,
holidays: holidays, holidays: holidays,
timegrid: timegrid, timegrid: timegrid,
customEvents: customEvents, customEvents: customEvents,
+8 -16
View File
@@ -7,8 +7,8 @@ import 'remote_file_ref.dart';
/// Server-side WebDAV copy of [source] into [targetFolderPath]. On a 412 /// Server-side WebDAV copy of [source] into [targetFolderPath]. On a 412
/// conflict the user is asked whether to overwrite; on confirmation the call /// conflict the user is asked whether to overwrite; on confirmation the call
/// is retried with `overwrite: true`. Returns true when the file ended up at /// is retried with `overwrite: true` inside the dialog. Returns true when the
/// the target, false when the user cancelled. /// file ended up at the target, false when the user cancelled.
Future<bool> copyRemoteFileTo({ Future<bool> copyRemoteFileTo({
required BuildContext context, required BuildContext context,
required RemoteFileRef source, required RemoteFileRef source,
@@ -27,19 +27,11 @@ Future<bool> copyRemoteFileTo({
} on DynamiteApiException catch (e) { } on DynamiteApiException catch (e) {
if (e.statusCode != 412) rethrow; if (e.statusCode != 412) rethrow;
if (!context.mounted) return false; if (!context.mounted) return false;
final overwrite = await showDialog<bool>( return ConfirmDialog(
context: context, title: 'Datei existiert bereits',
builder: (ctx) => ConfirmDialog( content: '"${source.name}" existiert in /$targetFolderPath. Überschreiben?',
title: 'Datei existiert bereits', confirmButton: 'Überschreiben',
content: onConfirmAsync: () => webdav.copy(src, dstUri, overwrite: true),
'"${source.name}" existiert in /$targetFolderPath. Überschreiben?', ).ask(context);
confirmButton: 'Überschreiben',
cancelButton: 'Abbrechen',
onConfirm: () => Navigator.of(ctx).pop(true),
),
);
if (overwrite != true) return false;
await webdav.copy(src, dstUri, overwrite: true);
return true;
} }
} }
@@ -154,20 +154,15 @@ class _ShareOptionsBodyState extends State<_ShareOptionsBody> {
_update(const ShareUpdateParams(password: '')); _update(const ShareUpdateParams(password: ''));
Future<void> _confirmDelete() async { Future<void> _confirmDelete() async {
// Use showDialog directly (not asDialog) so we get the AsyncDialogAction's // Close the options sheet only after the dialog returned — popping inside
// `true` result and can close the options sheet afterwards — popping inside
// onConfirmAsync would target the dialog route, not the sheet. // onConfirmAsync would target the dialog route, not the sheet.
final dialog = ConfirmDialog( final deleted = await ConfirmDialog(
title: 'Freigabe löschen?', title: 'Freigabe löschen?',
content: 'Die Freigabe wird aufgehoben.', content: 'Die Freigabe wird aufgehoben.',
confirmButton: 'Löschen', confirmButton: 'Löschen',
onConfirmAsync: () => FileSharingApi().remove(_share.id), onConfirmAsync: () => FileSharingApi().remove(_share.id),
); ).ask(context);
final deleted = await showDialog<bool>( if (!deleted || !mounted) return;
context: context,
builder: dialog.build,
);
if (deleted != true || !mounted) return;
widget.onChanged(); widget.onChanged();
if (Navigator.of(context).canPop()) Navigator.of(context).pop(); if (Navigator.of(context).canPop()) Navigator.of(context).pop();
} }
@@ -61,16 +61,11 @@ class _AccountSectionState extends State<AccountSection> {
if (result == null || !mounted) return; if (result == null || !mounted) return;
if (result is AvatarRemoveResult) { if (result is AvatarRemoveResult) {
var confirmed = false; final confirmed = await const ConfirmDialog(
await showDialog<void>( title: 'Profilbild entfernen',
context: context, content: 'Möchtest du dein Profilbild wirklich entfernen?',
builder: (_) => ConfirmDialog( confirmButton: 'Entfernen',
title: 'Profilbild entfernen', ).ask(context);
content: 'Möchtest du dein Profilbild wirklich entfernen?',
confirmButton: 'Entfernen',
onConfirm: () => confirmed = true,
),
);
if (!confirmed || !mounted) return; if (!confirmed || !mounted) return;
} }
@@ -211,20 +206,17 @@ Future<void> _confirmLogout(BuildContext context) async {
// Flip AccountBloc state only after the dialog fully closes: doing it from // Flip AccountBloc state only after the dialog fully closes: doing it from
// inside the sign-out (the previous approach) raced AsyncDialogAction's // inside the sign-out (the previous approach) raced AsyncDialogAction's
// pop(true) against the navigator teardown of the account switch. // pop(true) against the navigator teardown of the account switch.
final confirmed = await showDialog<bool>( final confirmed = await ConfirmDialog(
context: context, title: 'Abmelden?',
builder: (dialogContext) => ConfirmDialog( content: others > 0
title: 'Abmelden?', ? 'Möchtest du dich wirklich abmelden? Die App wechselt danach zu '
content: others > 0 'einem deiner anderen Konten.'
? 'Möchtest du dich wirklich abmelden? Die App wechselt danach zu ' : 'Möchtest du dich wirklich abmelden?',
'einem deiner anderen Konten.' confirmButton: 'Abmelden',
: 'Möchtest du dich wirklich abmelden?', onConfirmAsync: () async =>
confirmButton: 'Abmelden', nextAccountId = await SessionLifecycle.signOut(),
onConfirmAsync: () async => ).ask(context);
nextAccountId = await SessionLifecycle.signOut(), if (!confirmed) return;
),
);
if (confirmed != true) return;
accountBloc.activated(nextAccountId); accountBloc.activated(nextAccountId);
} }
+5 -10
View File
@@ -108,16 +108,11 @@ class _ChatInfoState extends State<ChatInfo> {
if (result == null || !mounted) return; if (result == null || !mounted) return;
if (result is AvatarRemoveResult) { if (result is AvatarRemoveResult) {
var confirmed = false; final confirmed = await const ConfirmDialog(
await showDialog<void>( title: 'Gruppenbild entfernen',
context: context, content: 'Möchtest du das Gruppenbild wirklich entfernen?',
builder: (_) => ConfirmDialog( confirmButton: 'Entfernen',
title: 'Gruppenbild entfernen', ).ask(context);
content: 'Möchtest du das Gruppenbild wirklich entfernen?',
confirmButton: 'Entfernen',
onConfirm: () => confirmed = true,
),
);
if (!confirmed || !mounted) return; if (!confirmed || !mounted) return;
} }
+9 -13
View File
@@ -32,17 +32,13 @@ class FavoriteToggleTile extends StatelessWidget {
/// Resolves to true once the room was left. /// Resolves to true once the room was left.
Future<bool> confirmLeaveRoom(BuildContext context, String token) async { Future<bool> confirmLeaveRoom(BuildContext context, String token) async {
final chatList = context.read<ChatListBloc>(); final chatList = context.read<ChatListBloc>();
final left = await showDialog<bool>( return ConfirmDialog(
context: context, title: 'Talk-Chat verlassen',
builder: (_) => ConfirmDialog( content: 'Du benötigst ggf. eine Einladung um erneut beizutreten.',
title: 'Talk-Chat verlassen', confirmButton: 'Verlassen',
content: 'Du benötigst ggf. eine Einladung um erneut beizutreten.', onConfirmAsync: () async {
confirmButton: 'Verlassen', await LeaveRoom(token).run();
onConfirmAsync: () async { unawaited(chatList.refresh());
await LeaveRoom(token).run(); },
unawaited(chatList.refresh()); ).ask(context);
},
),
);
return left ?? false;
} }
@@ -31,6 +31,7 @@ class AsyncActionButton extends StatelessWidget {
errorBuilder: errorBuilder, errorBuilder: errorBuilder,
onError: onError, onError: onError,
onSuccess: onSuccess, onSuccess: onSuccess,
showInlineError: showInlineError,
builder: (context, busy, handler) { builder: (context, busy, handler) {
final lead = busy final lead = busy
? AppProgressIndicator.small( ? AppProgressIndicator.small(
@@ -38,13 +39,11 @@ class AsyncActionButton extends StatelessWidget {
) )
: (icon == null ? null : Icon(icon)); : (icon == null ? null : Icon(icon));
final content = lead == null ? child : _leadingLabel(lead, child); final content = lead == null ? child : _leadingLabel(lead, child);
final button = ElevatedButton( return ElevatedButton(
onPressed: handler, onPressed: handler,
style: style, style: style,
child: content, child: content,
); );
if (!showInlineError) return button;
return _InlineErrorWrapper(controller: controller, child: button);
}, },
); );
} }
+8 -6
View File
@@ -6,6 +6,7 @@ class _AsyncMixin extends StatefulWidget {
final AsyncErrorBuilder? errorBuilder; final AsyncErrorBuilder? errorBuilder;
final void Function(String message)? onError; final void Function(String message)? onError;
final VoidCallback? onSuccess; final VoidCallback? onSuccess;
final bool showInlineError;
final Widget Function(BuildContext context, bool busy, VoidCallback? handler) final Widget Function(BuildContext context, bool busy, VoidCallback? handler)
builder; builder;
@@ -16,6 +17,7 @@ class _AsyncMixin extends StatefulWidget {
this.errorBuilder, this.errorBuilder,
this.onError, this.onError,
this.onSuccess, this.onSuccess,
this.showInlineError = false,
}); });
@override @override
@@ -73,27 +75,27 @@ class _AsyncMixinState extends State<_AsyncMixin> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final busy = _controller.busy; final busy = _controller.busy;
return widget.builder( final child = widget.builder(
context, context,
busy, busy,
busy || widget.onPressed == null ? null : _trigger, busy || widget.onPressed == null ? null : _trigger,
); );
if (!widget.showInlineError) return child;
return _InlineErrorWrapper(controller: _controller, child: child);
} }
} }
class _InlineErrorWrapper extends StatelessWidget { class _InlineErrorWrapper extends StatelessWidget {
final AsyncActionController? controller; final AsyncActionController controller;
final Widget child; final Widget child;
const _InlineErrorWrapper({required this.controller, required this.child}); const _InlineErrorWrapper({required this.controller, required this.child});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final c = controller;
if (c == null) return child;
return AnimatedBuilder( return AnimatedBuilder(
animation: c, animation: controller,
builder: (context, _) { builder: (context, _) {
final err = c.error; final err = controller.error;
return Column( return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
@@ -27,6 +27,7 @@ class AsyncTextButton extends StatelessWidget {
errorBuilder: errorBuilder, errorBuilder: errorBuilder,
onError: onError, onError: onError,
onSuccess: onSuccess, onSuccess: onSuccess,
showInlineError: showInlineError,
builder: (context, busy, handler) { builder: (context, busy, handler) {
final content = busy final content = busy
? _leadingLabel( ? _leadingLabel(
@@ -37,8 +38,7 @@ class AsyncTextButton extends StatelessWidget {
) )
: child; : child;
final button = TextButton(onPressed: handler, child: content); final button = TextButton(onPressed: handler, child: content);
if (!showInlineError) return button; return button;
return _InlineErrorWrapper(controller: controller, child: button);
}, },
); );
} }
+11 -6
View File
@@ -13,6 +13,9 @@ class ConfirmDialog extends StatelessWidget {
/// Label of the cancel button. Set to `null` for a single-button dialog /// Label of the cancel button. Set to `null` for a single-button dialog
/// (only the confirm action is rendered). /// (only the confirm action is rendered).
final String? cancelButton; final String? cancelButton;
/// Optional: without [onConfirm]/[onConfirmAsync] the dialog only reports
/// the decision via [ask].
final void Function()? onConfirm; final void Function()? onConfirm;
final AsyncActionCallback? onConfirmAsync; final AsyncActionCallback? onConfirmAsync;
final AsyncErrorBuilder? errorBuilder; final AsyncErrorBuilder? errorBuilder;
@@ -27,15 +30,17 @@ class ConfirmDialog extends StatelessWidget {
this.onConfirm, this.onConfirm,
this.onConfirmAsync, this.onConfirmAsync,
this.errorBuilder, this.errorBuilder,
}) : assert( });
onConfirm != null || onConfirmAsync != null,
'ConfirmDialog requires either onConfirm or onConfirmAsync',
);
void asDialog(BuildContext context) { void asDialog(BuildContext context) {
showDialog(context: context, builder: build); showDialog(context: context, builder: build);
} }
/// Resolves to true once confirmed – with [onConfirmAsync] only after it
/// succeeded.
Future<bool> ask(BuildContext context) async =>
await showDialog<bool>(context: context, builder: build) ?? false;
@override @override
Widget build(BuildContext context) => AlertDialog( Widget build(BuildContext context) => AlertDialog(
icon: icon != null ? Icon(icon) : null, icon: icon != null ? Icon(icon) : null,
@@ -59,8 +64,8 @@ class ConfirmDialog extends StatelessWidget {
TextButton( TextButton(
onPressed: () { onPressed: () {
Haptics.confirm(); Haptics.confirm();
Navigator.of(context).pop(); Navigator.of(context).pop(true);
onConfirm!(); onConfirm?.call();
}, },
child: Text(confirmButton), child: Text(confirmButton),
), ),
+10 -36
View File
@@ -2,7 +2,6 @@ import 'dart:developer';
import 'dart:ui'; import 'dart:ui';
import '../api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays_response.dart'; import '../api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays_response.dart';
import '../api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms_response.dart';
import '../api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.dart'; import '../api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.dart';
import '../api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid_response.dart'; import '../api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid_response.dart';
import '../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart'; import '../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
@@ -27,14 +26,14 @@ class WidgetDataMapper {
static const _weekend = {DateTime.saturday, DateTime.sunday}; static const _weekend = {DateTime.saturday, DateTime.sunday};
static DateTime resolveDayAnchor(DateTime now) { static DateTime resolveDayAnchor(DateTime now) {
var candidate = DateTime(now.year, now.month, now.day); var candidate = now.dateOnly;
final shiftToTomorrow = final shiftToTomorrow =
now.hour >= _dayWidgetCutoffHour || _weekend.contains(now.weekday); now.hour >= _dayWidgetCutoffHour || _weekend.contains(now.weekday);
if (shiftToTomorrow) { if (shiftToTomorrow) {
candidate = candidate.add(const Duration(days: 1)); candidate = candidate.addDays(1);
} }
while (_weekend.contains(candidate.weekday)) { while (_weekend.contains(candidate.weekday)) {
candidate = candidate.add(const Duration(days: 1)); candidate = candidate.addDays(1);
} }
return candidate; return candidate;
} }
@@ -44,10 +43,8 @@ class WidgetDataMapper {
/// Monday of the calendar week containing [reference] — no roll-forward, /// Monday of the calendar week containing [reference] — no roll-forward,
/// unlike [resolveWeekAnchor]. Start of the week payload's 14-day window. /// unlike [resolveWeekAnchor]. Start of the week payload's 14-day window.
static DateTime startOfCalendarWeek(DateTime reference) { static DateTime startOfCalendarWeek(DateTime reference) =>
final monday = reference.subtract(Duration(days: reference.weekday - 1)); reference.mondayOfWeek;
return DateTime(monday.year, monday.month, monday.day);
}
/// Days covered by the week payload: current calendar week + the next, so /// Days covered by the week payload: current calendar week + the next, so
/// native renderers can roll the view forward without fresh data. /// native renderers can roll the view forward without fresh data.
@@ -58,7 +55,6 @@ class WidgetDataMapper {
required DateTime now, required DateTime now,
required Iterable<McTimetableEntry> lessons, required Iterable<McTimetableEntry> lessons,
required TimetableGetSubjectsResponse? subjects, required TimetableGetSubjectsResponse? subjects,
required TimetableGetRoomsResponse? rooms,
required TimetableGetHolidaysResponse? holidays, required TimetableGetHolidaysResponse? holidays,
TimetableGetTimegridResponse? timegrid, TimetableGetTimegridResponse? timegrid,
GetCustomTimetableEventResponse? customEvents, GetCustomTimetableEventResponse? customEvents,
@@ -69,7 +65,6 @@ class WidgetDataMapper {
now: now, now: now,
lessons: lessons, lessons: lessons,
subjects: subjects, subjects: subjects,
rooms: rooms,
holidays: holidays, holidays: holidays,
timegrid: timegrid, timegrid: timegrid,
customEvents: customEvents, customEvents: customEvents,
@@ -80,7 +75,6 @@ class WidgetDataMapper {
now: now, now: now,
lessons: lessons, lessons: lessons,
subjects: subjects, subjects: subjects,
rooms: rooms,
holidays: holidays, holidays: holidays,
timegrid: timegrid, timegrid: timegrid,
customEvents: customEvents, customEvents: customEvents,
@@ -93,7 +87,6 @@ class WidgetDataMapper {
required DateTime now, required DateTime now,
required Iterable<McTimetableEntry> lessons, required Iterable<McTimetableEntry> lessons,
required TimetableGetSubjectsResponse? subjects, required TimetableGetSubjectsResponse? subjects,
required TimetableGetRoomsResponse? rooms,
required TimetableGetHolidaysResponse? holidays, required TimetableGetHolidaysResponse? holidays,
TimetableGetTimegridResponse? timegrid, TimetableGetTimegridResponse? timegrid,
GetCustomTimetableEventResponse? customEvents, GetCustomTimetableEventResponse? customEvents,
@@ -103,7 +96,7 @@ class WidgetDataMapper {
final anchor = resolveDayAnchor(now); final anchor = resolveDayAnchor(now);
final holiday = _findHoliday(anchor, holidays); final holiday = _findHoliday(anchor, holidays);
final dayStart = anchor; final dayStart = anchor;
final dayEnd = anchor.add(const Duration(days: 1)); final dayEnd = anchor.addDays(1);
final dayLessons = lessons.where((l) => l.date.isSameDay(anchor)).toList(); final dayLessons = lessons.where((l) => l.date.isSameDay(anchor)).toList();
final source = connectDoubleLessons final source = connectDoubleLessons
? LessonMerger.merge(dayLessons) ? LessonMerger.merge(dayLessons)
@@ -115,7 +108,6 @@ class WidgetDataMapper {
source, source,
now, now,
subjects, subjects,
rooms,
customEvents, customEvents,
dayStart, dayStart,
dayEnd, dayEnd,
@@ -131,7 +123,6 @@ class WidgetDataMapper {
required DateTime now, required DateTime now,
required Iterable<McTimetableEntry> lessons, required Iterable<McTimetableEntry> lessons,
required TimetableGetSubjectsResponse? subjects, required TimetableGetSubjectsResponse? subjects,
required TimetableGetRoomsResponse? rooms,
required TimetableGetHolidaysResponse? holidays, required TimetableGetHolidaysResponse? holidays,
TimetableGetTimegridResponse? timegrid, TimetableGetTimegridResponse? timegrid,
GetCustomTimetableEventResponse? customEvents, GetCustomTimetableEventResponse? customEvents,
@@ -143,7 +134,7 @@ class WidgetDataMapper {
// (possibly rolled-forward) week anchor: on Friday evening the payload // (possibly rolled-forward) week anchor: on Friday evening the payload
// must still contain today for renderers that derive day slices. // must still contain today for renderers that derive day slices.
final windowStart = startOfCalendarWeek(now); final windowStart = startOfCalendarWeek(now);
final endExclusive = windowStart.add(const Duration(days: weekWindowDays)); final endExclusive = windowStart.addDays(weekWindowDays);
final weekLessons = lessons.where((l) { final weekLessons = lessons.where((l) {
final dt = l.startDateTime; final dt = l.startDateTime;
return !dt.isBefore(windowStart) && dt.isBefore(endExclusive); return !dt.isBefore(windowStart) && dt.isBefore(endExclusive);
@@ -170,7 +161,6 @@ class WidgetDataMapper {
source, source,
now, now,
subjects, subjects,
rooms,
customEvents, customEvents,
windowStart, windowStart,
endExclusive, endExclusive,
@@ -340,7 +330,6 @@ class WidgetDataMapper {
Iterable<McTimetableEntry> source, Iterable<McTimetableEntry> source,
DateTime now, DateTime now,
TimetableGetSubjectsResponse? subjects, TimetableGetSubjectsResponse? subjects,
TimetableGetRoomsResponse? rooms,
GetCustomTimetableEventResponse? customEvents, GetCustomTimetableEventResponse? customEvents,
DateTime start, DateTime start,
DateTime endExclusive, { DateTime endExclusive, {
@@ -352,7 +341,6 @@ class WidgetDataMapper {
lesson, lesson,
now, now,
subjects, subjects,
rooms,
showClassInsteadOfTeacher: showClassInsteadOfTeacher, showClassInsteadOfTeacher: showClassInsteadOfTeacher,
), ),
..._expandCustomEvents(customEvents, start, endExclusive), ..._expandCustomEvents(customEvents, start, endExclusive),
@@ -363,8 +351,7 @@ class WidgetDataMapper {
static WidgetLesson _mapLesson( static WidgetLesson _mapLesson(
McTimetableEntry lesson, McTimetableEntry lesson,
DateTime now, DateTime now,
TimetableGetSubjectsResponse? subjects, TimetableGetSubjectsResponse? subjects, {
TimetableGetRoomsResponse? rooms, {
required bool showClassInsteadOfTeacher, required bool showClassInsteadOfTeacher,
}) { }) {
final start = lesson.startDateTime; final start = lesson.startDateTime;
@@ -389,15 +376,6 @@ class WidgetDataMapper {
.where((s) => s.shortName == subjectShortRaw) .where((s) => s.shortName == subjectShortRaw)
.firstOrNull; .firstOrNull;
final subjectLong = subject?.longName; final subjectLong = subject?.longName;
final roomShort = lesson.rooms.firstOrNull;
var roomName = roomShort;
if (rooms != null && roomShort != null) {
roomName = rooms.result
.where((r) => r.shortName == roomShort)
.firstOrNull
?.shortName ??
roomName;
}
final teacher = lesson.teachers.firstOrNull; final teacher = lesson.teachers.firstOrNull;
// Lehrerpläne: Klasse in den Teacher-Slot mappen, damit die nativen // Lehrerpläne: Klasse in den Teacher-Slot mappen, damit die nativen
// Renderer unverändert bleiben. Klassenlose Einträge (Aufsichten) lassen // Renderer unverändert bleiben. Klassenlose Einträge (Aufsichten) lassen
@@ -413,7 +391,7 @@ class WidgetDataMapper {
end: end, end: end,
subjectShort: subjectShort, subjectShort: subjectShort,
subjectLong: subjectLong, subjectLong: subjectLong,
room: roomName, room: lesson.rooms.firstOrNull,
teacher: teacherName, teacher: teacherName,
originalTeacher: originalTeacher, originalTeacher: originalTeacher,
status: _mapStatus(lessonStatus), status: _mapStatus(lessonStatus),
@@ -562,11 +540,7 @@ class WidgetDataMapper {
final actualEnd = occurrenceStart.add(duration); final actualEnd = occurrenceStart.add(duration);
var segmentStart = occurrenceStart; var segmentStart = occurrenceStart;
while (segmentStart.isBefore(actualEnd)) { while (segmentStart.isBefore(actualEnd)) {
final nextMidnight = DateTime( final nextMidnight = segmentStart.dateOnly.addDays(1);
segmentStart.year,
segmentStart.month,
segmentStart.day,
).add(const Duration(days: 1));
final segmentEnd = actualEnd.isBefore(nextMidnight) final segmentEnd = actualEnd.isBefore(nextMidnight)
? actualEnd ? actualEnd
: nextMidnight.subtract(const Duration(minutes: 1)); : nextMidnight.subtract(const Duration(minutes: 1));
-1
View File
@@ -85,7 +85,6 @@ class WidgetPublisher {
now: now, now: now,
lessons: state.getAllKnownLessons(), lessons: state.getAllKnownLessons(),
subjects: state.subjects, subjects: state.subjects,
rooms: state.rooms,
holidays: state.schoolHolidays, holidays: state.schoolHolidays,
timegrid: state.timegrid, timegrid: state.timegrid,
customEvents: state.customEvents, customEvents: state.customEvents,
+8 -15
View File
@@ -97,6 +97,14 @@ void main() {
); );
expect(anchor, DateTime(2026, 5, 11)); expect(anchor, DateTime(2026, 5, 11));
}); });
// Only discriminating when run in a DST zone (e.g. TZ=Europe/Berlin).
test('stays at midnight across the DST fall-back weekend', () {
final anchor = WidgetDataMapper.resolveDayAnchor(
DateTime(2026, 10, 25, 12),
);
expect(anchor, DateTime(2026, 10, 26));
});
}); });
group('resolveWeekAnchor', () { group('resolveWeekAnchor', () {
@@ -137,7 +145,6 @@ void main() {
now: now, now: now,
lessons: lessons, lessons: lessons,
subjects: null, subjects: null,
rooms: null,
holidays: null, holidays: null,
); );
expect(data.lessons, hasLength(1)); expect(data.lessons, hasLength(1));
@@ -174,7 +181,6 @@ void main() {
now: now, now: now,
lessons: lessons, lessons: lessons,
subjects: null, subjects: null,
rooms: null,
holidays: null, holidays: null,
); );
expect(data.lessons.map((l) => l.status).toList(), [ expect(data.lessons.map((l) => l.status).toList(), [
@@ -199,7 +205,6 @@ void main() {
now: now, now: now,
lessons: const [], lessons: const [],
subjects: null, subjects: null,
rooms: null,
holidays: holidays, holidays: holidays,
); );
expect(data.isHoliday, isTrue); expect(data.isHoliday, isTrue);
@@ -231,7 +236,6 @@ void main() {
now: now, now: now,
lessons: lessons, lessons: lessons,
subjects: null, subjects: null,
rooms: null,
holidays: null, holidays: null,
); );
expect(data.lessons.map((l) => l.subjectShort).toList(), [ expect(data.lessons.map((l) => l.subjectShort).toList(), [
@@ -278,7 +282,6 @@ void main() {
now: now, now: now,
lessons: lessons, lessons: lessons,
subjects: null, subjects: null,
rooms: null,
holidays: null, holidays: null,
customEvents: events, customEvents: events,
); );
@@ -315,7 +318,6 @@ void main() {
now: now, now: now,
lessons: lessons, lessons: lessons,
subjects: null, subjects: null,
rooms: null,
holidays: null, holidays: null,
customEvents: events, customEvents: events,
); );
@@ -336,7 +338,6 @@ void main() {
now: DateTime(2026, 5, 5, 10), now: DateTime(2026, 5, 5, 10),
lessons: const [], lessons: const [],
subjects: null, subjects: null,
rooms: null,
holidays: null, holidays: null,
customEvents: events, customEvents: events,
); );
@@ -376,7 +377,6 @@ void main() {
now: now, now: now,
lessons: lessons, lessons: lessons,
subjects: null, subjects: null,
rooms: null,
holidays: null, holidays: null,
customEvents: events, customEvents: events,
); );
@@ -411,7 +411,6 @@ void main() {
now: now, now: now,
lessons: [lesson], lessons: [lesson],
subjects: null, subjects: null,
rooms: null,
holidays: null, holidays: null,
showClassInsteadOfTeacher: true, showClassInsteadOfTeacher: true,
).lessons.single; ).lessons.single;
@@ -458,7 +457,6 @@ void main() {
now: now, now: now,
lessons: [lesson], lessons: [lesson],
subjects: subjects, subjects: subjects,
rooms: null,
holidays: null, holidays: null,
).lessons.single; ).lessons.single;
@@ -544,7 +542,6 @@ void main() {
now: now, now: now,
lessons: const [], lessons: const [],
subjects: null, subjects: null,
rooms: null,
holidays: null, holidays: null,
customEvents: GetCustomTimetableEventResponse([ customEvents: GetCustomTimetableEventResponse([
_event( _event(
@@ -599,7 +596,6 @@ void main() {
now: now, now: now,
lessons: lessons, lessons: lessons,
subjects: null, subjects: null,
rooms: null,
holidays: null, holidays: null,
); );
expect(data.anchorDate, DateTime(2026, 5, 4)); expect(data.anchorDate, DateTime(2026, 5, 4));
@@ -633,7 +629,6 @@ void main() {
now: fridayEvening, now: fridayEvening,
lessons: lessons, lessons: lessons,
subjects: null, subjects: null,
rooms: null,
holidays: null, holidays: null,
); );
expect(data.anchorDate, DateTime(2026, 5, 11)); expect(data.anchorDate, DateTime(2026, 5, 11));
@@ -655,7 +650,6 @@ void main() {
now: now, now: now,
lessons: const [], lessons: const [],
subjects: null, subjects: null,
rooms: null,
holidays: holidays, holidays: holidays,
); );
expect(data.days, hasLength(WidgetDataMapper.weekWindowDays)); expect(data.days, hasLength(WidgetDataMapper.weekWindowDays));
@@ -687,7 +681,6 @@ void main() {
now: now, now: now,
lessons: const [], lessons: const [],
subjects: null, subjects: null,
rooms: null,
holidays: holidays, holidays: holidays,
); );
expect(data.isHoliday, isTrue); expect(data.isHoliday, isTrue);