implemented current schoolyear API and dynamic timetable scroll boundaries, added handling for out-of-range errors to narrow accessible dates, optimized holiday region rendering by collapsing overlaps, and refined holiday tile UI
This commit is contained in:
@@ -2,7 +2,6 @@ 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';
|
||||
@@ -13,6 +12,7 @@ import 'custom_events/custom_event_edit_dialog.dart';
|
||||
import 'data/arbitrary_appointment.dart';
|
||||
import 'data/lesson_period_schedule.dart';
|
||||
import 'data/timetable_appointment_factory.dart';
|
||||
import 'data/webuntis_time.dart';
|
||||
import 'details/appointment_details_dispatcher.dart';
|
||||
import 'widgets/custom_workweek_calendar.dart';
|
||||
import 'widgets/special_regions_builder.dart';
|
||||
@@ -147,18 +147,20 @@ class _TimetableState extends State<Timetable> {
|
||||
disabledColor: Theme.of(context).disabledColor,
|
||||
).build();
|
||||
|
||||
// Scroll bounds follow the Webuntis school-year API: the calendar lets
|
||||
// the user navigate every week the server has data for. A two-week
|
||||
// fallback is used only while the school-year payload hasn't loaded yet
|
||||
// (first launch / offline), so the calendar still mounts.
|
||||
final (minDate, maxDate) = _scrollBounds(state);
|
||||
|
||||
return CustomWorkWeekCalendar(
|
||||
key: _calendarKey,
|
||||
schedule: schedule,
|
||||
appointments: appointments,
|
||||
timeRegions: regions,
|
||||
initialDate: _initialDisplayDate(),
|
||||
minDate: DateTime.now()
|
||||
.subtract(const Duration(days: 14))
|
||||
.nextWeekday(DateTime.sunday),
|
||||
maxDate: DateTime.now()
|
||||
.add(const Duration(days: 7))
|
||||
.nextWeekday(DateTime.saturday),
|
||||
minDate: minDate,
|
||||
maxDate: maxDate,
|
||||
onAppointmentTap: (apt) =>
|
||||
AppointmentDetailsDispatcher.show(context, bloc, apt),
|
||||
onWeekChanged: (start, end) => bloc.changeWeek(start, end),
|
||||
@@ -175,4 +177,43 @@ class _TimetableState extends State<Timetable> {
|
||||
barrierDismissible: false,
|
||||
);
|
||||
}
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// minDate is snapped *forward* to the next Monday because the calendar's
|
||||
/// internal `_mondayOf()` would otherwise pull a mid-week minDate back
|
||||
/// into the just-rejected week. maxDate is passed through unsnapped —
|
||||
/// `_mondayOf()` correctly walks back to the Monday of its own week,
|
||||
/// which is the last fully-allowed week.
|
||||
(DateTime, DateTime) _scrollBounds(TimetableState state) {
|
||||
final year = state.schoolyear?.result;
|
||||
final DateTime baseMin;
|
||||
final DateTime baseMax;
|
||||
if (year != null) {
|
||||
baseMin = WebuntisTime.parse(year.startDate, 0);
|
||||
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));
|
||||
}
|
||||
final effectiveMin = state.accessibleStartDate != null
|
||||
? (state.accessibleStartDate!.isAfter(baseMin)
|
||||
? state.accessibleStartDate!
|
||||
: baseMin)
|
||||
: baseMin;
|
||||
final effectiveMax = state.accessibleEndDate != null
|
||||
? (state.accessibleEndDate!.isBefore(baseMax)
|
||||
? state.accessibleEndDate!
|
||||
: baseMax)
|
||||
: baseMax;
|
||||
final daysToMonday =
|
||||
(DateTime.monday - effectiveMin.weekday) % DateTime.daysPerWeek;
|
||||
final mondayMin = effectiveMin.add(Duration(days: daysToMonday));
|
||||
return (mondayMin, effectiveMax);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +86,40 @@ class CustomWorkWeekCalendarState extends State<CustomWorkWeekCalendar> {
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant CustomWorkWeekCalendar oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.minDate == oldWidget.minDate &&
|
||||
widget.maxDate == oldWidget.maxDate) {
|
||||
return;
|
||||
}
|
||||
// Boundaries changed (e.g. school-year payload finished loading after
|
||||
// the initial mount with the conservative fallback). Recompute the
|
||||
// range and snap the controller to the page that still represents the
|
||||
// currently visible week so the user doesn't get yanked around.
|
||||
final newFirstMonday = _mondayOf(widget.minDate);
|
||||
final newLastMonday = _mondayOf(widget.maxDate);
|
||||
final newTotalWeeks =
|
||||
newLastMonday.difference(newFirstMonday).inDays ~/ 7 + 1;
|
||||
final visibleWeekStart = _firstMonday.add(
|
||||
Duration(days: _currentWeekIndex * 7),
|
||||
);
|
||||
final newIndex = visibleWeekStart
|
||||
.difference(newFirstMonday)
|
||||
.inDays
|
||||
~/
|
||||
7;
|
||||
final clampedIndex = newIndex.clamp(0, newTotalWeeks - 1);
|
||||
final oldController = _pageController;
|
||||
_pageController = PageController(initialPage: clampedIndex);
|
||||
oldController.dispose();
|
||||
setState(() {
|
||||
_firstMonday = newFirstMonday;
|
||||
_totalWeeks = newTotalWeeks;
|
||||
_currentWeekIndex = clampedIndex;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pageController.dispose();
|
||||
|
||||
@@ -62,33 +62,47 @@ class SpecialRegionsBuilder {
|
||||
|
||||
static String _dayKey(DateTime d) => '${d.year}-${d.month}-${d.day}';
|
||||
|
||||
Iterable<TimeRegion> _buildHolidayRegions() => holidays.result.expand((
|
||||
holiday,
|
||||
) {
|
||||
final startDay = WebuntisTime.parse(holiday.startDate, 0);
|
||||
final endDay = WebuntisTime.parse(holiday.endDate, 0);
|
||||
// Webuntis treats endDate inclusively (last day of the break) — the
|
||||
// `+ 1` covers single-day public holidays (where startDate == endDate)
|
||||
// and the final day of a multi-day vacation, both of which would
|
||||
// otherwise be skipped.
|
||||
final dayCount = endDay.difference(startDay).inDays + 1;
|
||||
final days = List<DateTime>.generate(
|
||||
dayCount,
|
||||
(i) => startDay.add(Duration(days: i)),
|
||||
);
|
||||
Iterable<TimeRegion> _buildHolidayRegions() {
|
||||
// Multiple Webuntis holiday entries can cover the same day (e.g. a
|
||||
// public holiday falling inside a vacation period). Collapse them
|
||||
// per-day so we emit exactly one TimeRegion per day and the
|
||||
// overlapping labels don't render on top of each other.
|
||||
final byDay = <String, _HolidayDay>{};
|
||||
for (final holiday in holidays.result) {
|
||||
final startDay = WebuntisTime.parse(holiday.startDate, 0);
|
||||
final endDay = WebuntisTime.parse(holiday.endDate, 0);
|
||||
// Webuntis treats endDate inclusively (last day of the break) — the
|
||||
// `+ 1` covers single-day public holidays (where startDate == endDate)
|
||||
// and the final day of a multi-day vacation, both of which would
|
||||
// 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 key = _dayKey(day);
|
||||
byDay.putIfAbsent(key, () => _HolidayDay(day, [])).names.add(
|
||||
holiday.name,
|
||||
);
|
||||
}
|
||||
}
|
||||
final gridStartHour = kCalendarStartHour.floor();
|
||||
final gridStartMinute = ((kCalendarStartHour - gridStartHour) * 60).round();
|
||||
final gridEndHour = kCalendarEndHour.floor();
|
||||
final gridEndMinute = ((kCalendarEndHour - gridEndHour) * 60).round();
|
||||
return days.map(
|
||||
(day) => TimeRegion(
|
||||
startTime: day.copyWith(hour: gridStartHour, minute: gridStartMinute),
|
||||
endTime: day.copyWith(hour: gridEndHour, minute: gridEndMinute),
|
||||
text: '$kTimeRegionHolidayPrefix${holiday.name}',
|
||||
return byDay.values.map(
|
||||
(entry) => TimeRegion(
|
||||
startTime: entry.day.copyWith(
|
||||
hour: gridStartHour,
|
||||
minute: gridStartMinute,
|
||||
),
|
||||
endTime: entry.day.copyWith(
|
||||
hour: gridEndHour,
|
||||
minute: gridEndMinute,
|
||||
),
|
||||
text: '$kTimeRegionHolidayPrefix${entry.names.join(" / ")}',
|
||||
color: disabledColor.withAlpha(50),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
TimeRegion _breakRegion(DateTime start, Duration duration) => TimeRegion(
|
||||
startTime: start,
|
||||
@@ -98,3 +112,9 @@ class SpecialRegionsBuilder {
|
||||
iconData: Icons.restaurant,
|
||||
);
|
||||
}
|
||||
|
||||
class _HolidayDay {
|
||||
final DateTime day;
|
||||
final List<String> names;
|
||||
_HolidayDay(this.day, this.names);
|
||||
}
|
||||
|
||||
@@ -41,7 +41,10 @@ class TimeRegionTile extends StatelessWidget {
|
||||
quarterTurns: 1,
|
||||
child: Text(
|
||||
text.substring(kTimeRegionHolidayPrefix.length),
|
||||
maxLines: 1,
|
||||
maxLines: 2,
|
||||
softWrap: true,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 15,
|
||||
|
||||
Reference in New Issue
Block a user