diff --git a/lib/extensions/date_time.dart b/lib/extensions/date_time.dart index 485b275..43fe685 100644 --- a/lib/extensions/date_time.dart +++ b/lib/extensions/date_time.dart @@ -36,6 +36,11 @@ extension CalendarDayArithmetic on DateTime { ); DateTime subtractDays(int days) => addDays(-days); + + DateTime get dateOnly => DateTime(year, month, day); + + /// Monday 00:00 of this date's week. + DateTime get mondayOfWeek => subtractDays(weekday - 1).dateOnly; } /// Formatting helpers backed by Jiffy. Centralises the patterns that previously diff --git a/lib/state/app/modules/foreign_timetable/bloc/foreign_timetable_bloc.dart b/lib/state/app/modules/foreign_timetable/bloc/foreign_timetable_bloc.dart index 12b7c78..4a58546 100644 --- a/lib/state/app/modules/foreign_timetable/bloc/foreign_timetable_bloc.dart +++ b/lib/state/app/modules/foreign_timetable/bloc/foreign_timetable_bloc.dart @@ -45,7 +45,7 @@ class ForeignTimetableBloc TimetableState fromNothing() { final reference = DateTime.now().addDays(2); return TimetableState( - startDate: _startOfWeek(reference), + startDate: reference.mondayOfWeek, endDate: _endOfWeek(reference), ); } @@ -101,7 +101,7 @@ class ForeignTimetableBloc void resetWeek() { final reference = DateTime.now().addDays(2); - changeWeek(_startOfWeek(reference), _endOfWeek(reference)); + changeWeek(reference.mondayOfWeek, _endOfWeek(reference)); } void refresh() => fetch(); @@ -180,19 +180,7 @@ class ForeignTimetableBloc } void _writeWeekToCache(DateTime weekStart, TimetableGetWeekResponse week) { - final key = weekStart.weekKey(); - add( - Emit((s) { - final updated = Map.of(s.weekCache); - updated[key] = week; - return s.copyWith(weekCache: updated, dataVersion: s.dataVersion + 1); - }), - ); - } - - static DateTime _startOfWeek(DateTime reference) { - final monday = reference.subtractDays(reference.weekday - 1); - return DateTime(monday.year, monday.month, monday.day); + add(Emit((s) => s.withFetchedWeek(weekStart, week))); } static DateTime _endOfWeek(DateTime reference) { diff --git a/lib/state/app/modules/timetable/bloc/timetable_bloc.dart b/lib/state/app/modules/timetable/bloc/timetable_bloc.dart index e797e37..60380a2 100644 --- a/lib/state/app/modules/timetable/bloc/timetable_bloc.dart +++ b/lib/state/app/modules/timetable/bloc/timetable_bloc.dart @@ -36,7 +36,7 @@ class TimetableBloc TimetableState fromNothing() { final reference = DateTime.now().addDays(2); return TimetableState( - startDate: _startOfWeek(reference), + startDate: reference.mondayOfWeek, endDate: _endOfWeek(reference), ); } @@ -49,7 +49,7 @@ class TimetableBloc final stored = TimetableState.fromJson(json); final reference = DateTime.now().addDays(2); return stored.copyWith( - startDate: _startOfWeek(reference), + startDate: reference.mondayOfWeek, endDate: _endOfWeek(reference), accessibleStartDate: null, accessibleEndDate: null, @@ -99,7 +99,7 @@ class TimetableBloc void resetWeek() { final reference = DateTime.now().addDays(2); - changeWeek(_startOfWeek(reference), _endOfWeek(reference)); + changeWeek(reference.mondayOfWeek, _endOfWeek(reference)); } void refresh() => fetch(); @@ -245,19 +245,7 @@ class TimetableBloc } void _writeWeekToCache(DateTime weekStart, TimetableGetWeekResponse week) { - final key = weekStart.weekKey(); - add( - Emit((s) { - final updated = Map.of(s.weekCache); - updated[key] = week; - return s.copyWith(weekCache: updated, dataVersion: s.dataVersion + 1); - }), - ); - } - - static DateTime _startOfWeek(DateTime reference) { - final monday = reference.subtractDays(reference.weekday - 1); - return DateTime(monday.year, monday.month, monday.day); + add(Emit((s) => s.withFetchedWeek(weekStart, week))); } static DateTime _endOfWeek(DateTime reference) { diff --git a/lib/state/app/modules/timetable/bloc/timetable_state.dart b/lib/state/app/modules/timetable/bloc/timetable_state.dart index df00dd7..12117a5 100644 --- a/lib/state/app/modules/timetable/bloc/timetable_state.dart +++ b/lib/state/app/modules/timetable/bloc/timetable_state.dart @@ -7,6 +7,7 @@ import '../../../../../api/marianumconnect/queries/timetable_get_subjects/timeta 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/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart'; +import 'week_cache.dart'; part 'timetable_state.freezed.dart'; part 'timetable_state.g.dart'; @@ -40,6 +41,23 @@ abstract class TimetableState with _$TimetableState { Iterable getAllKnownLessons() => weekCache.values.expand((response) => response.entries); + /// This state with [week] cached, or `this` when its content is unchanged + /// (so the emit is dropped as equal). + TimetableState withFetchedWeek( + DateTime weekStart, + TimetableGetWeekResponse week, + ) { + final updated = mergeWeekIntoCache( + weekCache, + weekStart, + week, + viewedWeekStart: startDate, + now: DateTime.now(), + ); + if (updated == null) return this; + return copyWith(weekCache: updated, dataVersion: dataVersion + 1); + } + bool get hasReferenceData => rooms != null && subjects != null && diff --git a/lib/state/app/modules/timetable/bloc/week_cache.dart b/lib/state/app/modules/timetable/bloc/week_cache.dart new file mode 100644 index 0000000..5281912 --- /dev/null +++ b/lib/state/app/modules/timetable/bloc/week_cache.dart @@ -0,0 +1,45 @@ +import 'package:collection/collection.dart'; + +import '../../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart'; +import '../../../../../extensions/date_time.dart'; + +/// Weeks kept around the viewed week and around today's week. Everything +/// further away is dropped: every state emit re-serializes the whole cache for +/// HydratedBloc and the calendar rebuilds its appointments from all of it, so +/// an unbounded cache makes week swipes slower the longer the app is used. +const int kWeekCacheRadius = 4; + +/// Returns the cache with [week] stored under [weekStart], pruned to the +/// weeks near [viewedWeekStart] or [now]. Returns null when the stored week +/// already has identical content, so callers can skip the emit entirely. +Map? mergeWeekIntoCache( + Map cache, + DateTime weekStart, + TimetableGetWeekResponse week, { + required DateTime viewedWeekStart, + required DateTime now, +}) { + final key = weekStart.weekKey(); + final existing = cache[key]; + if (existing != null && + const DeepCollectionEquality().equals(existing.toJson(), week.toJson())) { + return null; + } + + final viewedMonday = viewedWeekStart.mondayOfWeek; + final todayMonday = now.mondayOfWeek; + bool isNear(DateTime monday, DateTime anchor) => + monday.difference(anchor).inDays.abs() <= kWeekCacheRadius * 7 + 1; + + final updated = {}; + for (final entry in cache.entries) { + final monday = DateTime.tryParse(entry.key); + if (monday == null || + isNear(monday, viewedMonday) || + isNear(monday, todayMonday)) { + updated[entry.key] = entry.value; + } + } + updated[key] = week; + return updated; +} diff --git a/lib/view/pages/timetable/data/timetable_appointment_factory.dart b/lib/view/pages/timetable/data/timetable_appointment_factory.dart index c7ea181..0f9fa69 100644 --- a/lib/view/pages/timetable/data/timetable_appointment_factory.dart +++ b/lib/view/pages/timetable/data/timetable_appointment_factory.dart @@ -213,10 +213,12 @@ class TimetableAppointmentFactory { return LessonColor.forStatus(status); } - McSubject? _findSubject(String? subjectShort) { - if (subjectShort == null) return null; - return subjects.where((s) => s.shortName == subjectShort).firstOrNull; - } + late final Map _subjectsByShort = { + for (final s in subjects.reversed) s.shortName: s, + }; + + McSubject? _findSubject(String? subjectShort) => + subjectShort == null ? null : _subjectsByShort[subjectShort]; String _subjectName(String? subjectShort, McTimetableEntry lesson) { if (subjectShort != null) { diff --git a/lib/view/pages/timetable/widgets/appointment_tile.dart b/lib/view/pages/timetable/widgets/appointment_tile.dart index 0d8c1cf..a5ef8c8 100644 --- a/lib/view/pages/timetable/widgets/appointment_tile.dart +++ b/lib/view/pages/timetable/widgets/appointment_tile.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:syncfusion_flutter_calendar/calendar.dart'; @@ -11,16 +12,19 @@ class AppointmentTile extends StatelessWidget { final Appointment appointment; final bool crossedOut; + /// Ticking clock; only the tile background listens, so a lesson fades to + /// the "past" look without rebuilding the text layout. + final ValueListenable now; + const AppointmentTile({ super.key, required this.appointment, + required this.now, this.crossedOut = false, }); @override Widget build(BuildContext context) { - final isPast = appointment.endTime.isBefore(DateTime.now()); - final color = appointment.color.withAlpha(isPast ? 160 : 255); final isCustom = appointment.id is CustomAppointment; final description = appointment.location ?? ''; @@ -29,13 +33,19 @@ class AppointmentTile extends StatelessWidget { child: Stack( children: [ Positioned.fill( - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), - alignment: Alignment.topLeft, - decoration: BoxDecoration( - shape: BoxShape.rectangle, - borderRadius: _radius, - color: color, + child: ValueListenableBuilder( + valueListenable: now, + builder: (context, now, child) => Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + alignment: Alignment.topLeft, + decoration: BoxDecoration( + shape: BoxShape.rectangle, + borderRadius: _radius, + color: appointment.color.withAlpha( + appointment.endTime.isBefore(now) ? 160 : 255, + ), + ), + child: child, ), child: _TileContent( title: appointment.subject, @@ -100,8 +110,10 @@ class _TileContent extends StatelessWidget { // entirely; the coloured rectangle is enough. if (available < titleLineHeight) return const SizedBox.shrink(); - final remaining = - (available - titleLineHeight).clamp(0.0, double.infinity); + final remaining = (available - titleLineHeight).clamp( + 0.0, + double.infinity, + ); final bodyLineCapacity = (remaining / bodyLineHeight).floor(); if (isCustom) { @@ -187,7 +199,9 @@ class _AdaptiveTitle extends StatelessWidget { maxLines: 1, textScaler: textScaler, )..layout(); - if (probe.width > constraints.maxWidth) { + final overflows = probe.width > constraints.maxWidth; + probe.dispose(); + if (overflows) { return Text( text, style: baseStyle.copyWith(fontSize: minFontSize), diff --git a/lib/view/pages/timetable/widgets/calendar/outside_chips.dart b/lib/view/pages/timetable/widgets/calendar/outside_chips.dart index 35ba38a..259b1e2 100644 --- a/lib/view/pages/timetable/widgets/calendar/outside_chips.dart +++ b/lib/view/pages/timetable/widgets/calendar/outside_chips.dart @@ -1,16 +1,14 @@ part of '../custom_workweek_calendar.dart'; class _OutsideHoursStrip extends StatelessWidget { - final DateTime weekStart; - final List appointments; + final List> outside; final double rulerWidth; final void Function(Appointment) onAppointmentTap; final bool Function(Appointment) isCrossedOut; const _OutsideHoursStrip({ super.key, - required this.weekStart, - required this.appointments, + required this.outside, required this.rulerWidth, required this.onAppointmentTap, required this.isCrossedOut, @@ -18,10 +16,6 @@ class _OutsideHoursStrip extends StatelessWidget { @override Widget build(BuildContext context) { - final outside = partitionAppointmentsForWeek( - appointments, - weekStart, - ).outside; if (outside.every((day) => day.isEmpty)) return const SizedBox.shrink(); final theme = Theme.of(context); diff --git a/lib/view/pages/timetable/widgets/calendar/week_grid.dart b/lib/view/pages/timetable/widgets/calendar/week_grid.dart index f46fa61..e2c6e0e 100644 --- a/lib/view/pages/timetable/widgets/calendar/week_grid.dart +++ b/lib/view/pages/timetable/widgets/calendar/week_grid.dart @@ -1,10 +1,31 @@ part of '../custom_workweek_calendar.dart'; +/// Per-week derived data, computed once per appointment/region set instead of +/// on every rebuild of a week page. +class _WeekData { + final List> inside; + final List> outside; + final List> regions; + + _WeekData._(this.inside, this.outside, this.regions); + + factory _WeekData.compute( + DateTime weekStart, + List appointments, + List timeRegions, + ) { + final partitioned = partitionAppointmentsForWeek(appointments, weekStart); + return _WeekData._(partitioned.inside, partitioned.outside, [ + for (var d = 0; d < 5; d++) + expandRegionsForDay(timeRegions, weekStart.addDays(d)), + ]); + } +} + class _WeekGrid extends StatelessWidget { final DateTime weekStart; final LessonPeriodSchedule schedule; - final List appointments; - final List timeRegions; + final _WeekData data; final void Function(Appointment) onAppointmentTap; final bool Function(Appointment) isCrossedOut; final void Function(DateTime start, DateTime end)? onCreateEvent; @@ -16,8 +37,7 @@ class _WeekGrid extends StatelessWidget { const _WeekGrid({ required this.weekStart, required this.schedule, - required this.appointments, - required this.timeRegions, + required this.data, required this.onAppointmentTap, required this.isCrossedOut, required this.onCreateEvent, @@ -29,8 +49,6 @@ class _WeekGrid extends StatelessWidget { @override Widget build(BuildContext context) { - final partitioned = partitionAppointmentsForWeek(appointments, weekStart); - return Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -48,8 +66,8 @@ class _WeekGrid extends StatelessWidget { child: _DayColumn( date: weekStart.addDays(d), schedule: schedule, - appointments: partitioned.inside[d], - timeRegions: timeRegions, + appointments: data.inside[d], + regions: data.regions[d], layout: layout, today: today, nowNotifier: nowNotifier, @@ -195,7 +213,7 @@ class _DayColumn extends StatelessWidget { final DateTime date; final LessonPeriodSchedule schedule; final List appointments; - final List timeRegions; + final List regions; final PeriodLayout layout; final DateTime today; final ValueListenable nowNotifier; @@ -207,7 +225,7 @@ class _DayColumn extends StatelessWidget { required this.date, required this.schedule, required this.appointments, - required this.timeRegions, + required this.regions, required this.layout, required this.today, required this.nowNotifier, @@ -280,12 +298,10 @@ class _DayColumn extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); - final dayAppointments = appointments; - final dayRegions = expandRegionsForDay(timeRegions, date); final isToday = date.isSameDay(today); - final isTablet = MediaQuery.of(context).size.shortestSide >= 600; - final laidOut = assignLanes(dayAppointments, maxLanes: isTablet ? 3 : 2); + final isTablet = MediaQuery.sizeOf(context).shortestSide >= 600; + final laidOut = assignLanes(appointments, maxLanes: isTablet ? 3 : 2); final dayName = DateFormat( 'EEEE', @@ -304,8 +320,7 @@ class _DayColumn extends StatelessWidget { sortKey: OrdinalSortKey(date.weekday.toDouble()), child: GestureDetector( behavior: HitTestBehavior.translucent, - onLongPressStart: (details) => - _handleLongPress(details, dayAppointments), + onLongPressStart: (details) => _handleLongPress(details, appointments), child: DecoratedBox( decoration: BoxDecoration( color: isToday ? theme.colorScheme.primary.withAlpha(14) : null, @@ -342,7 +357,7 @@ class _DayColumn extends StatelessWidget { color: theme.dividerColor.withAlpha(60), ), ), - for (final region in dayRegions) + for (final region in regions) Positioned( top: layout.yOfDateTime(region.start), height: @@ -379,6 +394,7 @@ class _DayColumn extends StatelessWidget { onTap: () => onAppointmentTap(appointment), child: AppointmentTile( appointment: appointment, + now: nowNotifier, crossedOut: isCrossedOut(appointment), ), ), diff --git a/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart b/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart index 690a749..6f4f108 100644 --- a/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart +++ b/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart @@ -61,29 +61,41 @@ class CustomWorkWeekCalendarState extends State { static const double _rulerWidth = 36; late PageController _pageController; - late int _currentWeekIndex; + // Drives header + all-day strip. Kept out of setState so a page change + // mid-swipe doesn't rebuild the PageView and every tile in it. + late final ValueNotifier _visibleWeekIndex; + late int _reportedWeekIndex; late DateTime _firstMonday; late int _totalWeeks; late Timer _ticker; late ValueNotifier _nowNotifier; - DateTime _today = _dateOnly(DateTime.now()); + DateTime _today = DateTime.now().dateOnly; + + final Map _weekData = {}; + // Returning the identical widget instance lets Flutter skip rebuilding a + // week page when the host rebuilds for unrelated reasons (e.g. the bloc + // recording the new week after a swipe). + final Map _gridCache = {}; + Object? _cacheKey; @override void initState() { super.initState(); - _firstMonday = _mondayOf(widget.minDate); - final lastMonday = _mondayOf(widget.maxDate); + _firstMonday = widget.minDate.mondayOfWeek; + final lastMonday = widget.maxDate.mondayOfWeek; _totalWeeks = lastMonday.difference(_firstMonday).inDays ~/ 7 + 1; - _currentWeekIndex = - _mondayOf(widget.initialDate).difference(_firstMonday).inDays ~/ 7; - _pageController = PageController(initialPage: _currentWeekIndex); + final initialIndex = + widget.initialDate.mondayOfWeek.difference(_firstMonday).inDays ~/ 7; + _visibleWeekIndex = ValueNotifier(initialIndex); + _reportedWeekIndex = initialIndex; + _pageController = PageController(initialPage: initialIndex); _nowNotifier = ValueNotifier(DateTime.now()); _ticker = Timer.periodic(const Duration(seconds: 30), (_) { if (!mounted) return; final now = DateTime.now(); _nowNotifier.value = now; - final newToday = _dateOnly(now); + final newToday = now.dateOnly; if (newToday != _today) setState(() => _today = newToday); }); } @@ -99,11 +111,11 @@ class CustomWorkWeekCalendarState extends State { // 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 newFirstMonday = widget.minDate.mondayOfWeek; + final newLastMonday = widget.maxDate.mondayOfWeek; final newTotalWeeks = newLastMonday.difference(newFirstMonday).inDays ~/ 7 + 1; - final visibleWeekStart = _firstMonday.addDays(_currentWeekIndex * 7); + final visibleWeekStart = _firstMonday.addDays(_visibleWeekIndex.value * 7); final newIndex = visibleWeekStart.difference(newFirstMonday).inDays ~/ 7; final clampedIndex = newIndex.clamp(0, newTotalWeeks - 1); final oldController = _pageController; @@ -112,8 +124,9 @@ class CustomWorkWeekCalendarState extends State { setState(() { _firstMonday = newFirstMonday; _totalWeeks = newTotalWeeks; - _currentWeekIndex = clampedIndex; }); + _visibleWeekIndex.value = clampedIndex; + _reportedWeekIndex = clampedIndex; } @override @@ -121,13 +134,12 @@ class CustomWorkWeekCalendarState extends State { _pageController.dispose(); _ticker.cancel(); _nowNotifier.dispose(); + _visibleWeekIndex.dispose(); super.dispose(); } - static DateTime _dateOnly(DateTime d) => DateTime(d.year, d.month, d.day); - void jumpToDate(DateTime date) { - final target = _mondayOf(date).difference(_firstMonday).inDays ~/ 7; + final target = date.mondayOfWeek.difference(_firstMonday).inDays ~/ 7; if (target < 0 || target >= _totalWeeks) return; _pageController.animateToPage( target, @@ -136,16 +148,79 @@ class CustomWorkWeekCalendarState extends State { ); } - static DateTime _mondayOf(DateTime d) { - final monday = d.subtractDays(d.weekday - 1); - return DateTime(monday.year, monday.month, monday.day); + /// Drops both per-week caches when any input they were built from changed. + void _syncCaches() { + final key = ( + widget.appointments, + widget.timeRegions, + widget.schedule, + widget.onCreateEvent == null, + _today, + _firstMonday, + ); + if (key == _cacheKey) return; + _cacheKey = key; + _weekData.clear(); + _gridCache.clear(); } - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final visibleWeekStart = _firstMonday.addDays(_currentWeekIndex * 7); + _WeekData _weekDataFor(int weekIndex) { + return _weekData.putIfAbsent( + weekIndex, + () => _WeekData.compute( + _firstMonday.addDays(weekIndex * 7), + widget.appointments, + widget.timeRegions, + ), + ); + } + /// Tells the host about the new week only once the swipe has settled, so + /// the resulting bloc emit + rebuild never lands inside the page animation. + bool _onPageScroll(ScrollNotification notification) { + if (notification is! ScrollEndNotification || notification.depth != 0) { + return false; + } + final index = _visibleWeekIndex.value; + if (index == _reportedWeekIndex) return false; + _reportedWeekIndex = index; + final weekStart = _firstMonday.addDays(index * 7); + widget.onWeekChanged(weekStart, weekStart.addDays(4)); + return false; + } + + void _onAppointmentTap(Appointment appointment) => + widget.onAppointmentTap(appointment); + + bool _isCrossedOut(Appointment appointment) => + widget.isCrossedOut(appointment); + + void _onCreateEvent(DateTime start, DateTime end) => + widget.onCreateEvent?.call(start, end); + + _WeekGrid _weekGrid(int weekIndex, PeriodLayout layout) { + final cached = _gridCache[weekIndex]; + if (cached != null && cached.layout.lessonHeight == layout.lessonHeight) { + return cached; + } + // Only the pages next to the visible one are ever alive. + _gridCache.removeWhere((i, _) => (i - weekIndex).abs() > 2); + return _gridCache[weekIndex] = _WeekGrid( + weekStart: _firstMonday.addDays(weekIndex * 7), + schedule: widget.schedule, + data: _weekDataFor(weekIndex), + onAppointmentTap: _onAppointmentTap, + isCrossedOut: _isCrossedOut, + onCreateEvent: widget.onCreateEvent == null ? null : _onCreateEvent, + today: _today, + nowNotifier: _nowNotifier, + rulerWidth: _rulerWidth, + layout: layout, + ); + } + + Widget _buildWeekHeader(int weekIndex) { + final visibleWeekStart = _firstMonday.addDays(weekIndex * 7); return Column( children: [ SizedBox( @@ -190,15 +265,29 @@ class CustomWorkWeekCalendarState extends State { FadeTransition(opacity: animation, child: child), child: _OutsideHoursStrip( key: ValueKey(visibleWeekStart), - weekStart: visibleWeekStart, - appointments: widget.appointments, + outside: _weekDataFor(weekIndex).outside, rulerWidth: _rulerWidth, - onAppointmentTap: widget.onAppointmentTap, - isCrossedOut: widget.isCrossedOut, + onAppointmentTap: _onAppointmentTap, + isCrossedOut: _isCrossedOut, ), ), ), ), + ], + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + _syncCaches(); + + return Column( + children: [ + ValueListenableBuilder( + valueListenable: _visibleWeekIndex, + builder: (_, weekIndex, _) => _buildWeekHeader(weekIndex), + ), Container(height: 0.5, color: theme.dividerColor.withAlpha(110)), Expanded( child: LayoutBuilder( @@ -225,30 +314,15 @@ class CustomWorkWeekCalendarState extends State { physics: const AlwaysScrollableScrollPhysics(), child: SizedBox( height: gridHeight, - child: PageView.builder( - controller: _pageController, - itemCount: _totalWeeks, - onPageChanged: (index) { - setState(() => _currentWeekIndex = index); - final weekStart = _firstMonday.addDays(index * 7); - widget.onWeekChanged(weekStart, weekStart.addDays(4)); - }, - itemBuilder: (_, weekIndex) { - final weekStart = _firstMonday.addDays(weekIndex * 7); - return _WeekGrid( - weekStart: weekStart, - schedule: widget.schedule, - appointments: widget.appointments, - timeRegions: widget.timeRegions, - onAppointmentTap: widget.onAppointmentTap, - isCrossedOut: widget.isCrossedOut, - onCreateEvent: widget.onCreateEvent, - today: _today, - nowNotifier: _nowNotifier, - rulerWidth: _rulerWidth, - layout: layout, - ); - }, + child: NotificationListener( + onNotification: _onPageScroll, + child: PageView.builder( + controller: _pageController, + itemCount: _totalWeeks, + onPageChanged: (index) => _visibleWeekIndex.value = index, + itemBuilder: (_, weekIndex) => + _weekGrid(weekIndex, layout), + ), ), ), ); diff --git a/lib/view/pages/timetable/widgets/timetable_calendar_view.dart b/lib/view/pages/timetable/widgets/timetable_calendar_view.dart index e7287a8..5224f1f 100644 --- a/lib/view/pages/timetable/widgets/timetable_calendar_view.dart +++ b/lib/view/pages/timetable/widgets/timetable_calendar_view.dart @@ -55,6 +55,13 @@ class TimetableCalendarViewState extends State { // the same identity checks the cache always used. (int, TimetableSettings, List, bool)? _cacheKey; + // Stable identities let the calendar reuse its per-week pages across + // rebuilds; rebuilding these every frame would invalidate that cache. + LessonPeriodSchedule? _schedule; + Object? _scheduleKey; + List? _regions; + Object? _regionsKey; + DateTime _initialDisplayDate() => DateTime.now().addDays(2); /// Snaps the calendar back to the current week. Exposed so host pages can @@ -90,7 +97,37 @@ class TimetableCalendarViewState extends State { ).build(); } - bool _isCrossedOut(Appointment appointment) { + LessonPeriodSchedule _scheduleFor(TimetableState state) { + if (_schedule != null && identical(_scheduleKey, state.timegrid)) { + return _schedule!; + } + _scheduleKey = state.timegrid; + return _schedule = LessonPeriodSchedule.fromState(state); + } + + List _regionsFor( + TimetableState state, + LessonPeriodSchedule schedule, + ) { + final theme = Theme.of(context); + final key = ( + state.schoolHolidays, + schedule, + theme.colorScheme, + theme.disabledColor, + DateTime.now().dateOnly, + ); + if (_regions != null && key == _regionsKey) return _regions!; + _regionsKey = key; + return _regions = SpecialRegionsBuilder( + holidays: state.schoolHolidays!, + schedule: schedule, + colorScheme: theme.colorScheme, + disabledColor: theme.disabledColor, + ).build(); + } + + static bool _isCrossedOut(Appointment appointment) { final id = appointment.id; if (id is LessonAppointment) return id.entry.status == 'CANCELLED'; return false; @@ -104,14 +141,9 @@ class TimetableCalendarViewState extends State { // transient null can never crash the build. if (state.schoolHolidays == null) return const SizedBox.shrink(); - final schedule = LessonPeriodSchedule.fromState(state); + final schedule = _scheduleFor(state); final appointments = _appointments(state); - final regions = SpecialRegionsBuilder( - holidays: state.schoolHolidays!, - schedule: schedule, - colorScheme: Theme.of(context).colorScheme, - disabledColor: Theme.of(context).disabledColor, - ).build(); + final regions = _regionsFor(state, schedule); final capabilities = context.watch(); final (minDate, maxDate) = _scrollBounds( @@ -171,8 +203,8 @@ class TimetableCalendarViewState extends State { ? state.accessibleEndDate! : baseMax) : baseMax; - final today = _startOfDay(DateTime.now()); - final todayMonday = _mondayOf(today); + final today = DateTime.now().dateOnly; + final todayMonday = today.mondayOfWeek; final currentWeekEnd = todayMonday.addDays(DateTime.daysPerWeek - 1); final capMin = pastDays == null ? null @@ -202,11 +234,6 @@ class TimetableCalendarViewState extends State { return (mondayMin, finalMax); } - static DateTime _mondayOf(DateTime d) => - _startOfDay(d.subtractDays(d.weekday - 1)); - - static DateTime _startOfDay(DateTime d) => DateTime(d.year, d.month, d.day); - static DateTime _earlier(DateTime a, DateTime b) => a.isBefore(b) ? a : b; static DateTime _later(DateTime a, DateTime b) => a.isAfter(b) ? a : b; diff --git a/test/extensions/date_time_test.dart b/test/extensions/date_time_test.dart index c1eb7be..cc1ac34 100644 --- a/test/extensions/date_time_test.dart +++ b/test/extensions/date_time_test.dart @@ -86,6 +86,15 @@ void main() { final d = DateTime(2026, 5, 18, 13, 45); expect(d.subtractDays(5), d.addDays(-5)); }); + + test('dateOnly drops the time of day', () { + expect(DateTime(2026, 9, 24, 13, 45).dateOnly, DateTime(2026, 9, 24)); + }); + + test('mondayOfWeek returns Monday midnight for any weekday', () { + expect(DateTime(2026, 9, 21).mondayOfWeek, DateTime(2026, 9, 21)); + expect(DateTime(2026, 9, 27, 23, 59).mondayOfWeek, DateTime(2026, 9, 21)); + }); }); group('DateTimeFormatting', () { diff --git a/test/state/week_cache_test.dart b/test/state/week_cache_test.dart new file mode 100644 index 0000000..5429a42 --- /dev/null +++ b/test/state/week_cache_test.dart @@ -0,0 +1,74 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart'; +import 'package:marianum_mobile/extensions/date_time.dart'; +import 'package:marianum_mobile/state/app/modules/timetable/bloc/week_cache.dart'; + +TimetableGetWeekResponse _week(DateTime monday, {String room = 'A101'}) => + TimetableGetWeekResponse( + from: monday, + until: monday.addDays(4), + entries: [ + McTimetableEntry( + id: 1, + date: monday, + startTime: DateTime(1970, 1, 1, 8), + endTime: DateTime(1970, 1, 1, 9), + subjects: const ['ma'], + teachers: const [], + rooms: [room], + classNames: const [], + lessonType: 'LESSON', + status: 'REGULAR', + substitutionText: null, + lessonText: null, + infoText: null, + ), + ], + ); + +void main() { + final monday = DateTime(2026, 9, 21); + + test('returns null when the stored week has identical content', () { + final cache = {monday.weekKey(): _week(monday)}; + final result = mergeWeekIntoCache( + cache, + monday, + _week(monday), + viewedWeekStart: monday, + now: monday, + ); + expect(result, isNull); + }); + + test('stores changed content', () { + final cache = {monday.weekKey(): _week(monday)}; + final changed = _week(monday, room: 'B202'); + final result = mergeWeekIntoCache( + cache, + monday, + changed, + viewedWeekStart: monday, + now: monday, + ); + expect(result![monday.weekKey()], same(changed)); + }); + + test('drops weeks far from both the viewed week and today', () { + final far = monday.addDays(7 * (kWeekCacheRadius + 3)); + final nearToday = monday.addDays(-7 * kWeekCacheRadius); + final viewed = monday.addDays(7 * (kWeekCacheRadius * 3)); + final cache = { + far.weekKey(): _week(far), + nearToday.weekKey(): _week(nearToday), + }; + final result = mergeWeekIntoCache( + cache, + viewed, + _week(viewed), + viewedWeekStart: viewed, + now: monday.addDays(2), + ); + expect(result!.keys, {nearToday.weekKey(), viewed.weekKey()}); + }); +}