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:
2026-05-22 15:08:30 +02:00
parent f185b3273a
commit 2858f910c9
10 changed files with 158 additions and 61 deletions
@@ -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();
+37 -19
View File
@@ -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,