implemented DST-safe date arithmetic with new addDays and subtractDays extensions, updated timetable state to reset view and scroll boundaries on initialization to prevent stale views, added hard caps to calendar navigation, and updated version to 1.0.3+52
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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<String, dynamic> json) =>
|
||||
TimetableState.fromJson(json);
|
||||
TimetableState fromStorage(Map<String, dynamic> 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<String, dynamic>? 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);
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ partitionAppointmentsForWeek(
|
||||
) {
|
||||
final inside = List<List<Appointment>>.generate(5, (_) => <Appointment>[]);
|
||||
final outside = List<List<Appointment>>.generate(5, (_) => <Appointment>[]);
|
||||
final weekEnd = weekStart.add(const Duration(days: 5));
|
||||
final weekEnd = weekStart.addDays(5);
|
||||
final weekStartUtc = weekStart.toUtc();
|
||||
final weekEndUtc = weekEnd.toUtc();
|
||||
|
||||
|
||||
@@ -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<Timetable> {
|
||||
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<Timetable> {
|
||||
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<Timetable> {
|
||||
);
|
||||
}
|
||||
|
||||
/// 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<Timetable> {
|
||||
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<Timetable> {
|
||||
? 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -101,9 +101,7 @@ class CustomWorkWeekCalendarState extends State<CustomWorkWeekCalendar> {
|
||||
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<CustomWorkWeekCalendar> {
|
||||
}
|
||||
|
||||
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<CustomWorkWeekCalendar> {
|
||||
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,
|
||||
|
||||
@@ -23,12 +23,12 @@ class SpecialRegionsBuilder {
|
||||
|
||||
List<TimeRegion> 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 = <TimeRegion>[];
|
||||
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,
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user