timetable performance optimization

This commit is contained in:
2026-09-25 22:27:10 +02:00
parent ef7d17033d
commit ed8e52f475
13 changed files with 391 additions and 137 deletions
+5
View File
@@ -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
@@ -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<String, TimetableGetWeekResponse>.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) {
@@ -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<String, TimetableGetWeekResponse>.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) {
@@ -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<McTimetableEntry> 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 &&
@@ -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<String, TimetableGetWeekResponse>? mergeWeekIntoCache(
Map<String, TimetableGetWeekResponse> 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 = <String, TimetableGetWeekResponse>{};
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;
}
@@ -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<String, McSubject> _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) {
@@ -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<DateTime> 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<DateTime>(
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),
@@ -1,16 +1,14 @@
part of '../custom_workweek_calendar.dart';
class _OutsideHoursStrip extends StatelessWidget {
final DateTime weekStart;
final List<Appointment> appointments;
final List<List<Appointment>> 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);
@@ -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<List<Appointment>> inside;
final List<List<Appointment>> outside;
final List<List<BoundRegion>> regions;
_WeekData._(this.inside, this.outside, this.regions);
factory _WeekData.compute(
DateTime weekStart,
List<Appointment> appointments,
List<TimeRegion> 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<Appointment> appointments;
final List<TimeRegion> 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<Appointment> appointments;
final List<TimeRegion> timeRegions;
final List<BoundRegion> regions;
final PeriodLayout layout;
final DateTime today;
final ValueListenable<DateTime> 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),
),
),
@@ -61,29 +61,41 @@ class CustomWorkWeekCalendarState extends State<CustomWorkWeekCalendar> {
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<int> _visibleWeekIndex;
late int _reportedWeekIndex;
late DateTime _firstMonday;
late int _totalWeeks;
late Timer _ticker;
late ValueNotifier<DateTime> _nowNotifier;
DateTime _today = _dateOnly(DateTime.now());
DateTime _today = DateTime.now().dateOnly;
final Map<int, _WeekData> _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<int, _WeekGrid> _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<int>(initialIndex);
_reportedWeekIndex = initialIndex;
_pageController = PageController(initialPage: initialIndex);
_nowNotifier = ValueNotifier<DateTime>(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<CustomWorkWeekCalendar> {
// 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<CustomWorkWeekCalendar> {
setState(() {
_firstMonday = newFirstMonday;
_totalWeeks = newTotalWeeks;
_currentWeekIndex = clampedIndex;
});
_visibleWeekIndex.value = clampedIndex;
_reportedWeekIndex = clampedIndex;
}
@override
@@ -121,13 +134,12 @@ class CustomWorkWeekCalendarState extends State<CustomWorkWeekCalendar> {
_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<CustomWorkWeekCalendar> {
);
}
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<CustomWorkWeekCalendar> {
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<int>(
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<CustomWorkWeekCalendar> {
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<ScrollNotification>(
onNotification: _onPageScroll,
child: PageView.builder(
controller: _pageController,
itemCount: _totalWeeks,
onPageChanged: (index) => _visibleWeekIndex.value = index,
itemBuilder: (_, weekIndex) =>
_weekGrid(weekIndex, layout),
),
),
),
);
@@ -55,6 +55,13 @@ class TimetableCalendarViewState extends State<TimetableCalendarView> {
// the same identity checks the cache always used.
(int, TimetableSettings, List<CustomTimetableEvent>, 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<TimeRegion>? _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<TimetableCalendarView> {
).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<TimeRegion> _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<TimetableCalendarView> {
// 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<CapabilitiesCubit>();
final (minDate, maxDate) = _scrollBounds(
@@ -171,8 +203,8 @@ class TimetableCalendarViewState extends State<TimetableCalendarView> {
? 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<TimetableCalendarView> {
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;
+9
View File
@@ -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', () {
+74
View File
@@ -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()});
});
}