diff --git a/lib/extensions/date_time.dart b/lib/extensions/date_time.dart index 665019b..763c087 100644 --- a/lib/extensions/date_time.dart +++ b/lib/extensions/date_time.dart @@ -6,7 +6,7 @@ extension IsSameDay on DateTime { year == other.year && month == other.month && day == other.day; DateTime nextWeekday(int day) => - add(Duration(days: (day - weekday) % DateTime.daysPerWeek)); + addDays((day - weekday) % DateTime.daysPerWeek); DateTime withTime(TimeOfDay time) => copyWith(hour: time.hour, minute: time.minute); @@ -23,6 +23,27 @@ extension IsSameDay on DateTime { bool isSameOrAfter(DateTime other) => isSameDateTime(other) || isAfter(other); } +/// Calendar-aware day arithmetic. `DateTime.add(Duration(days: n))` adds +/// `n * 24h` of real-world time, which on local DateTimes silently drifts by +/// ±1h across DST transitions — so 7 days from "Monday 00:00 CEST" before a +/// DST fall-back lands at "Sunday 23:00 CET", shifting the entire next week +/// onto the wrong calendar day. [addDays]/[subtractDays] normalize through +/// `DateTime(year, month, day + n)` so the wall-clock fields stay fixed. +extension CalendarDayArithmetic on DateTime { + DateTime addDays(int days) => DateTime( + year, + month, + day + days, + hour, + minute, + second, + millisecond, + microsecond, + ); + + DateTime subtractDays(int days) => addDays(-days); +} + /// Formatting helpers backed by Jiffy. Centralises the patterns that previously /// were repeated as `Jiffy.parseFromDateTime(dt).format(pattern: '...')`. extension DateTimeFormatting on DateTime { diff --git a/lib/state/app/modules/timetable/bloc/timetable_bloc.dart b/lib/state/app/modules/timetable/bloc/timetable_bloc.dart index 9f3428a..bd53fed 100644 --- a/lib/state/app/modules/timetable/bloc/timetable_bloc.dart +++ b/lib/state/app/modules/timetable/bloc/timetable_bloc.dart @@ -5,6 +5,7 @@ import 'package:intl/intl.dart'; import '../../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart'; import '../../../../../api/webuntis/queries/get_timetable/get_timetable_response.dart'; import '../../../../../api/webuntis/webuntis_error.dart'; +import '../../../../../extensions/date_time.dart'; import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart'; import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart'; import '../repository/timetable_repository.dart'; @@ -18,7 +19,6 @@ class TimetableBloc TimetableState, TimetableRepository > { - static const Duration _weekSpan = Duration(days: 7); static final DateFormat _weekKeyFormat = DateFormat('yyyyMMdd'); DateTime _lastWeekRequestStart = DateTime.fromMillisecondsSinceEpoch(0); @@ -38,16 +38,31 @@ class TimetableBloc @override TimetableState fromNothing() { - final reference = DateTime.now().add(const Duration(days: 2)); + final reference = DateTime.now().addDays(2); return TimetableState( startDate: _startOfWeek(reference), endDate: _endOfWeek(reference), ); } + /// Persisted state may carry a stale `startDate`/`endDate` from the user's + /// last view as well as `accessibleStartDate`/`accessibleEndDate` learned + /// from `-7004 no allowed date` errors during scroll. Both must reset on + /// every cold start: otherwise the calendar can mount on a months-old week + /// (e.g. last December's Christmas holidays) or get permanently clamped + /// inside a window Webuntis once refused — even though the server would + /// happily serve the user's current week now. @override - TimetableState fromStorage(Map json) => - TimetableState.fromJson(json); + TimetableState fromStorage(Map json) { + final stored = TimetableState.fromJson(json); + final reference = DateTime.now().addDays(2); + return stored.copyWith( + startDate: _startOfWeek(reference), + endDate: _endOfWeek(reference), + accessibleStartDate: null, + accessibleEndDate: null, + ); + } @override Map? toStorage(TimetableState state) => state.toJson(); @@ -89,7 +104,7 @@ class TimetableBloc } void resetWeek() { - final reference = DateTime.now().add(const Duration(days: 2)); + final reference = DateTime.now().addDays(2); changeWeek(_startOfWeek(reference), _endOfWeek(reference)); } @@ -161,14 +176,14 @@ class TimetableBloc add( Emit((s) { if (isPast) { - final candidate = endDate.add(const Duration(days: 1)); + final candidate = endDate.addDays(1); final current = s.accessibleStartDate; if (current != null && !candidate.isAfter(current)) return s; return s.copyWith(accessibleStartDate: candidate); } // Treat anything not strictly past as a forward-direction denial, // including the rare case where startDate == now. - final candidate = startDate.subtract(const Duration(days: 1)); + final candidate = startDate.subtractDays(1); final current = s.accessibleEndDate; if (current != null && !candidate.isBefore(current)) return s; return s.copyWith(accessibleEndDate: candidate); @@ -245,8 +260,8 @@ class TimetableBloc } void _prefetchAdjacentWeeks(DateTime start, DateTime end) { - _prefetchWeek(start.subtract(_weekSpan), end.subtract(_weekSpan)); - _prefetchWeek(start.add(_weekSpan), end.add(_weekSpan)); + _prefetchWeek(start.subtractDays(7), end.subtractDays(7)); + _prefetchWeek(start.addDays(7), end.addDays(7)); } void _prefetchWeek(DateTime start, DateTime end) { @@ -268,13 +283,13 @@ class TimetableBloc } static DateTime _startOfWeek(DateTime reference) { - final monday = reference.subtract(Duration(days: reference.weekday - 1)); + final monday = reference.subtractDays(reference.weekday - 1); return DateTime(monday.year, monday.month, monday.day); } static DateTime _endOfWeek(DateTime reference) { - final friday = reference.add( - Duration(days: DateTime.daysPerWeek - reference.weekday - 2), + final friday = reference.addDays( + DateTime.daysPerWeek - reference.weekday - 2, ); return DateTime(friday.year, friday.month, friday.day); } diff --git a/lib/view/pages/timetable/data/calendar_logic.dart b/lib/view/pages/timetable/data/calendar_logic.dart index 8560876..e0a4a03 100644 --- a/lib/view/pages/timetable/data/calendar_logic.dart +++ b/lib/view/pages/timetable/data/calendar_logic.dart @@ -83,7 +83,7 @@ partitionAppointmentsForWeek( ) { final inside = List>.generate(5, (_) => []); final outside = List>.generate(5, (_) => []); - final weekEnd = weekStart.add(const Duration(days: 5)); + final weekEnd = weekStart.addDays(5); final weekStartUtc = weekStart.toUtc(); final weekEndUtc = weekEnd.toUtc(); diff --git a/lib/view/pages/timetable/timetable.dart b/lib/view/pages/timetable/timetable.dart index 8104ba0..c08aaca 100644 --- a/lib/view/pages/timetable/timetable.dart +++ b/lib/view/pages/timetable/timetable.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:syncfusion_flutter_calendar/calendar.dart'; +import '../../../extensions/date_time.dart'; import '../../../routing/app_routes.dart'; import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart'; import '../../../state/app/modules/settings/bloc/settings_cubit.dart'; @@ -34,7 +35,7 @@ class _TimetableState extends State { int? _lastDataVersion; TimetableSettings? _lastTimetableSettings; - DateTime _initialDisplayDate() => DateTime.now().add(const Duration(days: 2)); + DateTime _initialDisplayDate() => DateTime.now().addDays(2); void _jumpToToday() { _calendarKey.currentState?.jumpToDate(_initialDisplayDate()); @@ -82,16 +83,8 @@ class _TimetableState extends State { return false; } - bool _isOnInitialWeek(TimetableState state) { - final target = _initialDisplayDate(); - final targetMonday = target.subtract(Duration(days: target.weekday - 1)); - final mondayOnly = DateTime( - targetMonday.year, - targetMonday.month, - targetMonday.day, - ); - return state.startDate == mondayOnly; - } + bool _isOnInitialWeek(TimetableState state) => + state.startDate == _mondayOf(_initialDisplayDate()); @override Widget build(BuildContext context) { @@ -178,11 +171,20 @@ class _TimetableState extends State { ); } + /// Hard caps applied on top of whatever Webuntis would allow. Even if the + /// school year (or a stale persisted bound) would let the user scroll + /// further, we never expose more than this much around the current week — + /// containment for any future date-math bug that might otherwise teleport + /// the user months away from today. + static const int _maxWeeksBack = 4; + static const int _maxWeeksForward = 2; + /// Returns the (minDate, maxDate) the user is allowed to scroll between. /// Starts from the Webuntis school year (or a tight window when that - /// hasn't loaded yet) and tightens by anything the bloc has learned - /// from `-7004 no allowed date` errors during scroll — so the user - /// can't slide off into territory Webuntis would refuse anyway. + /// hasn't loaded yet), tightens by anything the bloc has learned from + /// `-7004 no allowed date` errors during scroll — so the user can't + /// slide off into territory Webuntis would refuse anyway — and finally + /// clamps to a fixed window around today. /// /// minDate is snapped *forward* to the next Monday because the calendar's /// internal `_mondayOf()` would otherwise pull a mid-week minDate back @@ -198,8 +200,8 @@ class _TimetableState extends State { baseMax = WebuntisTime.parse(year.endDate, 0); } else { final now = DateTime.now(); - baseMin = now.subtract(const Duration(days: 14)); - baseMax = now.add(const Duration(days: 7)); + baseMin = now.subtractDays(14); + baseMax = now.addDays(7); } final effectiveMin = state.accessibleStartDate != null ? (state.accessibleStartDate!.isAfter(baseMin) @@ -211,9 +213,25 @@ class _TimetableState extends State { ? state.accessibleEndDate! : baseMax) : baseMax; + final todayMonday = _mondayOf(DateTime.now()); + final cappedMin = effectiveMin.isBefore( + todayMonday.subtractDays(_maxWeeksBack * 7), + ) + ? todayMonday.subtractDays(_maxWeeksBack * 7) + : effectiveMin; + final cappedMax = effectiveMax.isAfter( + todayMonday.addDays(_maxWeeksForward * 7 + 6), + ) + ? todayMonday.addDays(_maxWeeksForward * 7 + 6) + : effectiveMax; final daysToMonday = - (DateTime.monday - effectiveMin.weekday) % DateTime.daysPerWeek; - final mondayMin = effectiveMin.add(Duration(days: daysToMonday)); - return (mondayMin, effectiveMax); + (DateTime.monday - cappedMin.weekday) % DateTime.daysPerWeek; + final mondayMin = cappedMin.addDays(daysToMonday); + return (mondayMin, cappedMax); + } + + static DateTime _mondayOf(DateTime d) { + final monday = d.subtractDays(d.weekday - 1); + return DateTime(monday.year, monday.month, monday.day); } } diff --git a/lib/view/pages/timetable/widgets/calendar/day_header.dart b/lib/view/pages/timetable/widgets/calendar/day_header.dart index 5f1ca7f..57cb421 100644 --- a/lib/view/pages/timetable/widgets/calendar/day_header.dart +++ b/lib/view/pages/timetable/widgets/calendar/day_header.dart @@ -19,7 +19,7 @@ class _DayHeaderStrip extends StatelessWidget { for (var d = 0; d < 5; d++) Expanded( child: _DayHeaderCell( - date: weekStart.add(Duration(days: d)), + date: weekStart.addDays(d), today: today, ), ), diff --git a/lib/view/pages/timetable/widgets/calendar/week_grid.dart b/lib/view/pages/timetable/widgets/calendar/week_grid.dart index 132298f..f5a0f65 100644 --- a/lib/view/pages/timetable/widgets/calendar/week_grid.dart +++ b/lib/view/pages/timetable/widgets/calendar/week_grid.dart @@ -38,7 +38,7 @@ class _WeekGrid extends StatelessWidget { for (var d = 0; d < 5; d++) Expanded( child: _DayColumn( - date: weekStart.add(Duration(days: d)), + date: weekStart.addDays(d), schedule: schedule, appointments: partitioned.inside[d], timeRegions: timeRegions, diff --git a/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart b/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart index 978e610..867670e 100644 --- a/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart +++ b/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart @@ -101,9 +101,7 @@ class CustomWorkWeekCalendarState extends State { final newLastMonday = _mondayOf(widget.maxDate); final newTotalWeeks = newLastMonday.difference(newFirstMonday).inDays ~/ 7 + 1; - final visibleWeekStart = _firstMonday.add( - Duration(days: _currentWeekIndex * 7), - ); + final visibleWeekStart = _firstMonday.addDays(_currentWeekIndex * 7); final newIndex = visibleWeekStart .difference(newFirstMonday) .inDays @@ -141,16 +139,14 @@ class CustomWorkWeekCalendarState extends State { } static DateTime _mondayOf(DateTime d) { - final monday = d.subtract(Duration(days: d.weekday - 1)); + final monday = d.subtractDays(d.weekday - 1); return DateTime(monday.year, monday.month, monday.day); } @override Widget build(BuildContext context) { final theme = Theme.of(context); - final visibleWeekStart = _firstMonday.add( - Duration(days: _currentWeekIndex * 7), - ); + final visibleWeekStart = _firstMonday.addDays(_currentWeekIndex * 7); return Column( children: [ @@ -231,18 +227,11 @@ class CustomWorkWeekCalendarState extends State { itemCount: _totalWeeks, onPageChanged: (index) { setState(() => _currentWeekIndex = index); - final weekStart = _firstMonday.add( - Duration(days: index * 7), - ); - widget.onWeekChanged( - weekStart, - weekStart.add(const Duration(days: 4)), - ); + final weekStart = _firstMonday.addDays(index * 7); + widget.onWeekChanged(weekStart, weekStart.addDays(4)); }, itemBuilder: (_, weekIndex) { - final weekStart = _firstMonday.add( - Duration(days: weekIndex * 7), - ); + final weekStart = _firstMonday.addDays(weekIndex * 7); return _WeekGrid( weekStart: weekStart, schedule: widget.schedule, diff --git a/lib/view/pages/timetable/widgets/special_regions_builder.dart b/lib/view/pages/timetable/widgets/special_regions_builder.dart index 2a42175..ce408b5 100644 --- a/lib/view/pages/timetable/widgets/special_regions_builder.dart +++ b/lib/view/pages/timetable/widgets/special_regions_builder.dart @@ -23,12 +23,12 @@ class SpecialRegionsBuilder { List build() { final rangeStart = DateTime.now() - .subtract(const Duration(days: 14)) + .subtractDays(14) .nextWeekday(DateTime.monday); // Far enough out to cover comfortable scrolling without rebuilding; // Syncfusion only paints regions that intersect the visible window so // the extra entries don't hurt rendering cost. - final rangeEnd = DateTime.now().add(const Duration(days: 180)); + final rangeEnd = DateTime.now().addDays(180); final holidayRegions = _buildHolidayRegions().toList(); final holidayDays = holidayRegions @@ -42,11 +42,7 @@ class SpecialRegionsBuilder { // explicit per-day regions and just skipping holidays is robust. final breakPeriods = schedule.periods.where((p) => p.isBreak).toList(); final breakRegions = []; - for ( - var day = rangeStart; - !day.isAfter(rangeEnd); - day = day.add(const Duration(days: 1)) - ) { + for (var day = rangeStart; !day.isAfter(rangeEnd); day = day.addDays(1)) { if (holidayDays.contains(_dayKey(day))) continue; for (final p in breakPeriods) { final start = day.copyWith( @@ -77,7 +73,7 @@ class SpecialRegionsBuilder { // otherwise be skipped. final dayCount = endDay.difference(startDay).inDays + 1; for (var i = 0; i < dayCount; i++) { - final day = startDay.add(Duration(days: i)); + final day = startDay.addDays(i); final key = _dayKey(day); byDay.putIfAbsent(key, () => _HolidayDay(day, [])).names.add( holiday.name, diff --git a/pubspec.yaml b/pubspec.yaml index 9f7ea00..50be876 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: Mobile client for Webuntis and Nextcloud with Talk integration publish_to: 'none' -version: 1.0.2+50 +version: 1.0.3+52 environment: sdk: ">=3.8.0 <4.0.0" diff --git a/test/extensions/date_time_test.dart b/test/extensions/date_time_test.dart index 8d97940..fa5b89b 100644 --- a/test/extensions/date_time_test.dart +++ b/test/extensions/date_time_test.dart @@ -30,6 +30,64 @@ void main() { }); }); + group('CalendarDayArithmetic', () { + test('addDays preserves wall-clock time on a normal day', () { + final d = DateTime(2026, 5, 18, 8, 30); + final next = d.addDays(1); + expect(next.year, 2026); + expect(next.month, 5); + expect(next.day, 19); + expect(next.hour, 8); + expect(next.minute, 30); + }); + + test('addDays normalises across month boundaries', () { + final d = DateTime(2026, 1, 30); + expect(d.addDays(3), DateTime(2026, 2, 2)); + }); + + test( + 'addDays(7 * N) keeps weekday across the October DST fall-back, ' + 'unlike Duration(days: 7 * N)', + () { + // Sep 1, 2025 is a Monday (CEST). 16 weeks later is Dec 22, 2025, + // also a Monday (CET) — but `add(Duration(days: 112))` lands at + // Sunday Dec 21 23:00 because DST drains an hour in October. + final mondayBeforeDst = DateTime(2025, 9, 1); + final mondayAfterDst = mondayBeforeDst.addDays(7 * 16); + expect(mondayAfterDst.weekday, DateTime.monday); + expect(mondayAfterDst.hour, 0); + expect(mondayAfterDst.day, 22); + expect(mondayAfterDst.month, 12); + }, + // Test only meaningful when running in a DST-aware timezone (e.g. + // Europe/Berlin). In UTC `Duration` arithmetic happens to be safe + // too, so the assertion still holds; we keep it unconditional. + ); + + test( + 'subtractDays(7) crossing March DST spring-forward stays on Monday', + () { + // Mon Apr 6 2026 (CEST, UTC+2) - 1 week should still be Mon Mar 30 + // 2026 (CEST). With Duration(days:7) you'd get Mar 29 23:00 (CEST) + // - actually CET=>CEST: the transition is at 02:00 → 03:00 on + // Mar 29. Either way `addDays`/`subtractDays` must hit midnight. + final monApr6 = DateTime(2026, 4, 6); + final prev = monApr6.subtractDays(7); + expect(prev.weekday, DateTime.monday); + expect(prev.day, 30); + expect(prev.month, 3); + expect(prev.hour, 0); + expect(prev.minute, 0); + }, + ); + + test('subtractDays(n) is equivalent to addDays(-n)', () { + final d = DateTime(2026, 5, 18, 13, 45); + expect(d.subtractDays(5), d.addDays(-5)); + }); + }); + group('DateTimeFormatting', () { final dt = DateTime(2026, 5, 8, 9, 7);