fixed overwrite on internal copy, DST drift in widget anchors, inline errors without controller and removed dead room lookup
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user