diff --git a/lib/api/webuntis/queries/get_current_schoolyear/get_current_schoolyear.dart b/lib/api/webuntis/queries/get_current_schoolyear/get_current_schoolyear.dart new file mode 100644 index 0000000..195fe14 --- /dev/null +++ b/lib/api/webuntis/queries/get_current_schoolyear/get_current_schoolyear.dart @@ -0,0 +1,18 @@ +import 'dart:convert'; + +import '../../webuntis_api.dart'; +import 'get_current_schoolyear_response.dart'; + +class GetCurrentSchoolyear extends WebuntisApi { + GetCurrentSchoolyear() : super('getCurrentSchoolyear', null); + + @override + Future run() async { + final rawAnswer = await query(this); + return finalize( + GetCurrentSchoolyearResponse.fromJson( + jsonDecode(rawAnswer) as Map, + ), + ); + } +} diff --git a/lib/api/webuntis/queries/get_current_schoolyear/get_current_schoolyear_cache.dart b/lib/api/webuntis/queries/get_current_schoolyear/get_current_schoolyear_cache.dart new file mode 100644 index 0000000..03afe61 --- /dev/null +++ b/lib/api/webuntis/queries/get_current_schoolyear/get_current_schoolyear_cache.dart @@ -0,0 +1,15 @@ +import '../../../request_cache.dart'; +import 'get_current_schoolyear.dart'; +import 'get_current_schoolyear_response.dart'; + +class GetCurrentSchoolyearCache + extends SimpleCache { + GetCurrentSchoolyearCache({super.onUpdate, super.onError, super.renew}) + : super( + cacheTime: RequestCache.cacheDay, + loader: () => GetCurrentSchoolyear().run(), + fromJson: GetCurrentSchoolyearResponse.fromJson, + ) { + start('wu-current-schoolyear'); + } +} diff --git a/lib/api/webuntis/queries/get_current_schoolyear/get_current_schoolyear_response.dart b/lib/api/webuntis/queries/get_current_schoolyear/get_current_schoolyear_response.dart new file mode 100644 index 0000000..57cefa1 --- /dev/null +++ b/lib/api/webuntis/queries/get_current_schoolyear/get_current_schoolyear_response.dart @@ -0,0 +1,39 @@ +import 'package:json_annotation/json_annotation.dart'; + +import '../../../api_response.dart'; + +part 'get_current_schoolyear_response.g.dart'; + +/// Wraps Webuntis' `getCurrentSchoolyear` payload. The server returns a +/// single object with the current school year's bounds (yyyyMMdd integers). +@JsonSerializable(explicitToJson: true) +class GetCurrentSchoolyearResponse extends ApiResponse { + GetCurrentSchoolyearResponseObject result; + + GetCurrentSchoolyearResponse(this.result); + + factory GetCurrentSchoolyearResponse.fromJson(Map json) => + _$GetCurrentSchoolyearResponseFromJson(json); + Map toJson() => _$GetCurrentSchoolyearResponseToJson(this); +} + +@JsonSerializable() +class GetCurrentSchoolyearResponseObject { + int id; + String name; + int startDate; + int endDate; + + GetCurrentSchoolyearResponseObject( + this.id, + this.name, + this.startDate, + this.endDate, + ); + + factory GetCurrentSchoolyearResponseObject.fromJson( + Map json, + ) => _$GetCurrentSchoolyearResponseObjectFromJson(json); + Map toJson() => + _$GetCurrentSchoolyearResponseObjectToJson(this); +} diff --git a/lib/api/webuntis/queries/get_current_schoolyear/get_current_schoolyear_response.g.dart b/lib/api/webuntis/queries/get_current_schoolyear/get_current_schoolyear_response.g.dart new file mode 100644 index 0000000..6505442 --- /dev/null +++ b/lib/api/webuntis/queries/get_current_schoolyear/get_current_schoolyear_response.g.dart @@ -0,0 +1,44 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'get_current_schoolyear_response.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +GetCurrentSchoolyearResponse _$GetCurrentSchoolyearResponseFromJson( + Map json, +) => + GetCurrentSchoolyearResponse( + GetCurrentSchoolyearResponseObject.fromJson( + json['result'] as Map, + ), + ) + ..headers = (json['headers'] as Map?)?.map( + (k, e) => MapEntry(k, e as String), + ); + +Map _$GetCurrentSchoolyearResponseToJson( + GetCurrentSchoolyearResponse instance, +) => { + 'headers': ?instance.headers, + 'result': instance.result.toJson(), +}; + +GetCurrentSchoolyearResponseObject _$GetCurrentSchoolyearResponseObjectFromJson( + Map json, +) => GetCurrentSchoolyearResponseObject( + (json['id'] as num).toInt(), + json['name'] as String, + (json['startDate'] as num).toInt(), + (json['endDate'] as num).toInt(), +); + +Map _$GetCurrentSchoolyearResponseObjectToJson( + GetCurrentSchoolyearResponseObject instance, +) => { + 'id': instance.id, + 'name': instance.name, + 'startDate': instance.startDate, + 'endDate': instance.endDate, +}; diff --git a/lib/state/app/modules/timetable/bloc/timetable_bloc.dart b/lib/state/app/modules/timetable/bloc/timetable_bloc.dart index ea0f985..9f3428a 100644 --- a/lib/state/app/modules/timetable/bloc/timetable_bloc.dart +++ b/lib/state/app/modules/timetable/bloc/timetable_bloc.dart @@ -1,7 +1,10 @@ +import 'dart:developer'; + 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 '../../../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'; @@ -124,20 +127,65 @@ class TimetableBloc ); if (_lastWeekRequestStart.isAfter(requestStart)) return; _writeWeekToCache(startDate, week); + } on WebuntisError catch (e) { + if (e.code == _outOfRangeErrorCode) { + _narrowAccessibleRange(startDate, endDate); + // Out-of-range is expected when the user scrolls into territory + // Webuntis doesn't grant access to — surface to UI as a normal + // empty week instead of letting the loadable state escalate it + // into a red error screen. + return; + } + log( + 'Webuntis getWeek error: code=${e.code} message="${e.message}" ' + 'for $startDate–$endDate', + ); + onError?.call(e); } catch (e) { onError?.call(e); } } + /// Webuntis returns this for weeks the user has no access to (typically + /// before the active enrolment / after a teacher's planning window). + static const int _outOfRangeErrorCode = -7004; + + /// Pulls the calendar's permitted scroll range inward based on a denied + /// week. We don't know the exact cutoff — only that *this* week is out + /// of reach — so we always pick the tighter of the existing bound and + /// the newly discovered one. Pre-now denials shrink the lower bound, + /// post-now denials the upper. + void _narrowAccessibleRange(DateTime startDate, DateTime endDate) { + final now = DateTime.now(); + final isPast = endDate.isBefore(now); + add( + Emit((s) { + if (isPast) { + final candidate = endDate.add(const Duration(days: 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 current = s.accessibleEndDate; + if (current != null && !candidate.isBefore(current)) return s; + return s.copyWith(accessibleEndDate: candidate); + }), + ); + } + Future _loadStaticReferenceData({ void Function(Object)? onError, bool renew = false, }) async { try { - final (rooms, subjects, schoolHolidays) = await ( + final (rooms, subjects, schoolHolidays, schoolyear) = await ( repo.data.getRooms(onError: onError, renew: renew), repo.data.getSubjects(onError: onError, renew: renew), repo.data.getSchoolHolidays(onError: onError, renew: renew), + repo.data.getCurrentSchoolyear(onError: onError, renew: renew), ).wait; add( @@ -146,6 +194,7 @@ class TimetableBloc rooms: rooms, subjects: subjects, schoolHolidays: schoolHolidays, + schoolyear: schoolyear, dataVersion: s.dataVersion + 1, ), ), diff --git a/lib/state/app/modules/timetable/bloc/timetable_state.dart b/lib/state/app/modules/timetable/bloc/timetable_state.dart index d99d0ee..1c180ae 100644 --- a/lib/state/app/modules/timetable/bloc/timetable_state.dart +++ b/lib/state/app/modules/timetable/bloc/timetable_state.dart @@ -1,6 +1,7 @@ import 'package:freezed_annotation/freezed_annotation.dart'; import '../../../../../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart'; +import '../../../../../api/webuntis/queries/get_current_schoolyear/get_current_schoolyear_response.dart'; import '../../../../../api/webuntis/queries/get_holidays/get_holidays_response.dart'; import '../../../../../api/webuntis/queries/get_rooms/get_rooms_response.dart'; import '../../../../../api/webuntis/queries/get_subjects/get_subjects_response.dart'; @@ -20,11 +21,18 @@ abstract class TimetableState with _$TimetableState { GetRoomsResponse? rooms, GetSubjectsResponse? subjects, GetHolidaysResponse? schoolHolidays, + GetCurrentSchoolyearResponse? schoolyear, GetTimegridUnitsResponse? timegrid, GetCustomTimetableEventResponse? customEvents, required DateTime startDate, required DateTime endDate, @Default(0) int dataVersion, + // Boundaries learned from `-7004 no allowed date` errors during scroll. + // Inclusive: weeks whose start is on/before `accessibleEndDate` and + // whose end is on/after `accessibleStartDate` are within the user's + // permitted range. Null = no upper / lower bound discovered yet. + DateTime? accessibleStartDate, + DateTime? accessibleEndDate, }) = _TimetableState; factory TimetableState.fromJson(Map json) => diff --git a/lib/state/app/modules/timetable/bloc/timetable_state.freezed.dart b/lib/state/app/modules/timetable/bloc/timetable_state.freezed.dart index 4af71be..74f0af4 100644 --- a/lib/state/app/modules/timetable/bloc/timetable_state.freezed.dart +++ b/lib/state/app/modules/timetable/bloc/timetable_state.freezed.dart @@ -15,7 +15,11 @@ T _$identity(T value) => value; /// @nodoc mixin _$TimetableState { - Map get weekCache; GetRoomsResponse? get rooms; GetSubjectsResponse? get subjects; GetHolidaysResponse? get schoolHolidays; GetTimegridUnitsResponse? get timegrid; GetCustomTimetableEventResponse? get customEvents; DateTime get startDate; DateTime get endDate; int get dataVersion; + Map get weekCache; GetRoomsResponse? get rooms; GetSubjectsResponse? get subjects; GetHolidaysResponse? get schoolHolidays; GetCurrentSchoolyearResponse? get schoolyear; GetTimegridUnitsResponse? get timegrid; GetCustomTimetableEventResponse? get customEvents; DateTime get startDate; DateTime get endDate; int get dataVersion;// Boundaries learned from `-7004 no allowed date` errors during scroll. +// Inclusive: weeks whose start is on/before `accessibleEndDate` and +// whose end is on/after `accessibleStartDate` are within the user's +// permitted range. Null = no upper / lower bound discovered yet. + DateTime? get accessibleStartDate; DateTime? get accessibleEndDate; /// Create a copy of TimetableState /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -28,16 +32,16 @@ $TimetableStateCopyWith get copyWith => _$TimetableStateCopyWith @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is TimetableState&&const DeepCollectionEquality().equals(other.weekCache, weekCache)&&(identical(other.rooms, rooms) || other.rooms == rooms)&&(identical(other.subjects, subjects) || other.subjects == subjects)&&(identical(other.schoolHolidays, schoolHolidays) || other.schoolHolidays == schoolHolidays)&&(identical(other.timegrid, timegrid) || other.timegrid == timegrid)&&(identical(other.customEvents, customEvents) || other.customEvents == customEvents)&&(identical(other.startDate, startDate) || other.startDate == startDate)&&(identical(other.endDate, endDate) || other.endDate == endDate)&&(identical(other.dataVersion, dataVersion) || other.dataVersion == dataVersion)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is TimetableState&&const DeepCollectionEquality().equals(other.weekCache, weekCache)&&(identical(other.rooms, rooms) || other.rooms == rooms)&&(identical(other.subjects, subjects) || other.subjects == subjects)&&(identical(other.schoolHolidays, schoolHolidays) || other.schoolHolidays == schoolHolidays)&&(identical(other.schoolyear, schoolyear) || other.schoolyear == schoolyear)&&(identical(other.timegrid, timegrid) || other.timegrid == timegrid)&&(identical(other.customEvents, customEvents) || other.customEvents == customEvents)&&(identical(other.startDate, startDate) || other.startDate == startDate)&&(identical(other.endDate, endDate) || other.endDate == endDate)&&(identical(other.dataVersion, dataVersion) || other.dataVersion == dataVersion)&&(identical(other.accessibleStartDate, accessibleStartDate) || other.accessibleStartDate == accessibleStartDate)&&(identical(other.accessibleEndDate, accessibleEndDate) || other.accessibleEndDate == accessibleEndDate)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(weekCache),rooms,subjects,schoolHolidays,timegrid,customEvents,startDate,endDate,dataVersion); +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(weekCache),rooms,subjects,schoolHolidays,schoolyear,timegrid,customEvents,startDate,endDate,dataVersion,accessibleStartDate,accessibleEndDate); @override String toString() { - return 'TimetableState(weekCache: $weekCache, rooms: $rooms, subjects: $subjects, schoolHolidays: $schoolHolidays, timegrid: $timegrid, customEvents: $customEvents, startDate: $startDate, endDate: $endDate, dataVersion: $dataVersion)'; + return 'TimetableState(weekCache: $weekCache, rooms: $rooms, subjects: $subjects, schoolHolidays: $schoolHolidays, schoolyear: $schoolyear, timegrid: $timegrid, customEvents: $customEvents, startDate: $startDate, endDate: $endDate, dataVersion: $dataVersion, accessibleStartDate: $accessibleStartDate, accessibleEndDate: $accessibleEndDate)'; } @@ -48,7 +52,7 @@ abstract mixin class $TimetableStateCopyWith<$Res> { factory $TimetableStateCopyWith(TimetableState value, $Res Function(TimetableState) _then) = _$TimetableStateCopyWithImpl; @useResult $Res call({ - Map weekCache, GetRoomsResponse? rooms, GetSubjectsResponse? subjects, GetHolidaysResponse? schoolHolidays, GetTimegridUnitsResponse? timegrid, GetCustomTimetableEventResponse? customEvents, DateTime startDate, DateTime endDate, int dataVersion + Map weekCache, GetRoomsResponse? rooms, GetSubjectsResponse? subjects, GetHolidaysResponse? schoolHolidays, GetCurrentSchoolyearResponse? schoolyear, GetTimegridUnitsResponse? timegrid, GetCustomTimetableEventResponse? customEvents, DateTime startDate, DateTime endDate, int dataVersion, DateTime? accessibleStartDate, DateTime? accessibleEndDate }); @@ -65,18 +69,21 @@ class _$TimetableStateCopyWithImpl<$Res> /// Create a copy of TimetableState /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? weekCache = null,Object? rooms = freezed,Object? subjects = freezed,Object? schoolHolidays = freezed,Object? timegrid = freezed,Object? customEvents = freezed,Object? startDate = null,Object? endDate = null,Object? dataVersion = null,}) { +@pragma('vm:prefer-inline') @override $Res call({Object? weekCache = null,Object? rooms = freezed,Object? subjects = freezed,Object? schoolHolidays = freezed,Object? schoolyear = freezed,Object? timegrid = freezed,Object? customEvents = freezed,Object? startDate = null,Object? endDate = null,Object? dataVersion = null,Object? accessibleStartDate = freezed,Object? accessibleEndDate = freezed,}) { return _then(_self.copyWith( weekCache: null == weekCache ? _self.weekCache : weekCache // ignore: cast_nullable_to_non_nullable as Map,rooms: freezed == rooms ? _self.rooms : rooms // ignore: cast_nullable_to_non_nullable as GetRoomsResponse?,subjects: freezed == subjects ? _self.subjects : subjects // ignore: cast_nullable_to_non_nullable as GetSubjectsResponse?,schoolHolidays: freezed == schoolHolidays ? _self.schoolHolidays : schoolHolidays // ignore: cast_nullable_to_non_nullable -as GetHolidaysResponse?,timegrid: freezed == timegrid ? _self.timegrid : timegrid // ignore: cast_nullable_to_non_nullable +as GetHolidaysResponse?,schoolyear: freezed == schoolyear ? _self.schoolyear : schoolyear // ignore: cast_nullable_to_non_nullable +as GetCurrentSchoolyearResponse?,timegrid: freezed == timegrid ? _self.timegrid : timegrid // ignore: cast_nullable_to_non_nullable as GetTimegridUnitsResponse?,customEvents: freezed == customEvents ? _self.customEvents : customEvents // ignore: cast_nullable_to_non_nullable as GetCustomTimetableEventResponse?,startDate: null == startDate ? _self.startDate : startDate // ignore: cast_nullable_to_non_nullable as DateTime,endDate: null == endDate ? _self.endDate : endDate // ignore: cast_nullable_to_non_nullable as DateTime,dataVersion: null == dataVersion ? _self.dataVersion : dataVersion // ignore: cast_nullable_to_non_nullable -as int, +as int,accessibleStartDate: freezed == accessibleStartDate ? _self.accessibleStartDate : accessibleStartDate // ignore: cast_nullable_to_non_nullable +as DateTime?,accessibleEndDate: freezed == accessibleEndDate ? _self.accessibleEndDate : accessibleEndDate // ignore: cast_nullable_to_non_nullable +as DateTime?, )); } @@ -161,10 +168,10 @@ return $default(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen(TResult Function( Map weekCache, GetRoomsResponse? rooms, GetSubjectsResponse? subjects, GetHolidaysResponse? schoolHolidays, GetTimegridUnitsResponse? timegrid, GetCustomTimetableEventResponse? customEvents, DateTime startDate, DateTime endDate, int dataVersion)? $default,{required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(TResult Function( Map weekCache, GetRoomsResponse? rooms, GetSubjectsResponse? subjects, GetHolidaysResponse? schoolHolidays, GetCurrentSchoolyearResponse? schoolyear, GetTimegridUnitsResponse? timegrid, GetCustomTimetableEventResponse? customEvents, DateTime startDate, DateTime endDate, int dataVersion, DateTime? accessibleStartDate, DateTime? accessibleEndDate)? $default,{required TResult orElse(),}) {final _that = this; switch (_that) { case _TimetableState() when $default != null: -return $default(_that.weekCache,_that.rooms,_that.subjects,_that.schoolHolidays,_that.timegrid,_that.customEvents,_that.startDate,_that.endDate,_that.dataVersion);case _: +return $default(_that.weekCache,_that.rooms,_that.subjects,_that.schoolHolidays,_that.schoolyear,_that.timegrid,_that.customEvents,_that.startDate,_that.endDate,_that.dataVersion,_that.accessibleStartDate,_that.accessibleEndDate);case _: return orElse(); } @@ -182,10 +189,10 @@ return $default(_that.weekCache,_that.rooms,_that.subjects,_that.schoolHolidays, /// } /// ``` -@optionalTypeArgs TResult when(TResult Function( Map weekCache, GetRoomsResponse? rooms, GetSubjectsResponse? subjects, GetHolidaysResponse? schoolHolidays, GetTimegridUnitsResponse? timegrid, GetCustomTimetableEventResponse? customEvents, DateTime startDate, DateTime endDate, int dataVersion) $default,) {final _that = this; +@optionalTypeArgs TResult when(TResult Function( Map weekCache, GetRoomsResponse? rooms, GetSubjectsResponse? subjects, GetHolidaysResponse? schoolHolidays, GetCurrentSchoolyearResponse? schoolyear, GetTimegridUnitsResponse? timegrid, GetCustomTimetableEventResponse? customEvents, DateTime startDate, DateTime endDate, int dataVersion, DateTime? accessibleStartDate, DateTime? accessibleEndDate) $default,) {final _that = this; switch (_that) { case _TimetableState(): -return $default(_that.weekCache,_that.rooms,_that.subjects,_that.schoolHolidays,_that.timegrid,_that.customEvents,_that.startDate,_that.endDate,_that.dataVersion);case _: +return $default(_that.weekCache,_that.rooms,_that.subjects,_that.schoolHolidays,_that.schoolyear,_that.timegrid,_that.customEvents,_that.startDate,_that.endDate,_that.dataVersion,_that.accessibleStartDate,_that.accessibleEndDate);case _: throw StateError('Unexpected subclass'); } @@ -202,10 +209,10 @@ return $default(_that.weekCache,_that.rooms,_that.subjects,_that.schoolHolidays, /// } /// ``` -@optionalTypeArgs TResult? whenOrNull(TResult? Function( Map weekCache, GetRoomsResponse? rooms, GetSubjectsResponse? subjects, GetHolidaysResponse? schoolHolidays, GetTimegridUnitsResponse? timegrid, GetCustomTimetableEventResponse? customEvents, DateTime startDate, DateTime endDate, int dataVersion)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(TResult? Function( Map weekCache, GetRoomsResponse? rooms, GetSubjectsResponse? subjects, GetHolidaysResponse? schoolHolidays, GetCurrentSchoolyearResponse? schoolyear, GetTimegridUnitsResponse? timegrid, GetCustomTimetableEventResponse? customEvents, DateTime startDate, DateTime endDate, int dataVersion, DateTime? accessibleStartDate, DateTime? accessibleEndDate)? $default,) {final _that = this; switch (_that) { case _TimetableState() when $default != null: -return $default(_that.weekCache,_that.rooms,_that.subjects,_that.schoolHolidays,_that.timegrid,_that.customEvents,_that.startDate,_that.endDate,_that.dataVersion);case _: +return $default(_that.weekCache,_that.rooms,_that.subjects,_that.schoolHolidays,_that.schoolyear,_that.timegrid,_that.customEvents,_that.startDate,_that.endDate,_that.dataVersion,_that.accessibleStartDate,_that.accessibleEndDate);case _: return null; } @@ -217,7 +224,7 @@ return $default(_that.weekCache,_that.rooms,_that.subjects,_that.schoolHolidays, @JsonSerializable() class _TimetableState extends TimetableState { - const _TimetableState({final Map weekCache = const {}, this.rooms, this.subjects, this.schoolHolidays, this.timegrid, this.customEvents, required this.startDate, required this.endDate, this.dataVersion = 0}): _weekCache = weekCache,super._(); + const _TimetableState({final Map weekCache = const {}, this.rooms, this.subjects, this.schoolHolidays, this.schoolyear, this.timegrid, this.customEvents, required this.startDate, required this.endDate, this.dataVersion = 0, this.accessibleStartDate, this.accessibleEndDate}): _weekCache = weekCache,super._(); factory _TimetableState.fromJson(Map json) => _$TimetableStateFromJson(json); final Map _weekCache; @@ -230,11 +237,18 @@ class _TimetableState extends TimetableState { @override final GetRoomsResponse? rooms; @override final GetSubjectsResponse? subjects; @override final GetHolidaysResponse? schoolHolidays; +@override final GetCurrentSchoolyearResponse? schoolyear; @override final GetTimegridUnitsResponse? timegrid; @override final GetCustomTimetableEventResponse? customEvents; @override final DateTime startDate; @override final DateTime endDate; @override@JsonKey() final int dataVersion; +// Boundaries learned from `-7004 no allowed date` errors during scroll. +// Inclusive: weeks whose start is on/before `accessibleEndDate` and +// whose end is on/after `accessibleStartDate` are within the user's +// permitted range. Null = no upper / lower bound discovered yet. +@override final DateTime? accessibleStartDate; +@override final DateTime? accessibleEndDate; /// Create a copy of TimetableState /// with the given fields replaced by the non-null parameter values. @@ -249,16 +263,16 @@ Map toJson() { @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _TimetableState&&const DeepCollectionEquality().equals(other._weekCache, _weekCache)&&(identical(other.rooms, rooms) || other.rooms == rooms)&&(identical(other.subjects, subjects) || other.subjects == subjects)&&(identical(other.schoolHolidays, schoolHolidays) || other.schoolHolidays == schoolHolidays)&&(identical(other.timegrid, timegrid) || other.timegrid == timegrid)&&(identical(other.customEvents, customEvents) || other.customEvents == customEvents)&&(identical(other.startDate, startDate) || other.startDate == startDate)&&(identical(other.endDate, endDate) || other.endDate == endDate)&&(identical(other.dataVersion, dataVersion) || other.dataVersion == dataVersion)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is _TimetableState&&const DeepCollectionEquality().equals(other._weekCache, _weekCache)&&(identical(other.rooms, rooms) || other.rooms == rooms)&&(identical(other.subjects, subjects) || other.subjects == subjects)&&(identical(other.schoolHolidays, schoolHolidays) || other.schoolHolidays == schoolHolidays)&&(identical(other.schoolyear, schoolyear) || other.schoolyear == schoolyear)&&(identical(other.timegrid, timegrid) || other.timegrid == timegrid)&&(identical(other.customEvents, customEvents) || other.customEvents == customEvents)&&(identical(other.startDate, startDate) || other.startDate == startDate)&&(identical(other.endDate, endDate) || other.endDate == endDate)&&(identical(other.dataVersion, dataVersion) || other.dataVersion == dataVersion)&&(identical(other.accessibleStartDate, accessibleStartDate) || other.accessibleStartDate == accessibleStartDate)&&(identical(other.accessibleEndDate, accessibleEndDate) || other.accessibleEndDate == accessibleEndDate)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_weekCache),rooms,subjects,schoolHolidays,timegrid,customEvents,startDate,endDate,dataVersion); +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_weekCache),rooms,subjects,schoolHolidays,schoolyear,timegrid,customEvents,startDate,endDate,dataVersion,accessibleStartDate,accessibleEndDate); @override String toString() { - return 'TimetableState(weekCache: $weekCache, rooms: $rooms, subjects: $subjects, schoolHolidays: $schoolHolidays, timegrid: $timegrid, customEvents: $customEvents, startDate: $startDate, endDate: $endDate, dataVersion: $dataVersion)'; + return 'TimetableState(weekCache: $weekCache, rooms: $rooms, subjects: $subjects, schoolHolidays: $schoolHolidays, schoolyear: $schoolyear, timegrid: $timegrid, customEvents: $customEvents, startDate: $startDate, endDate: $endDate, dataVersion: $dataVersion, accessibleStartDate: $accessibleStartDate, accessibleEndDate: $accessibleEndDate)'; } @@ -269,7 +283,7 @@ abstract mixin class _$TimetableStateCopyWith<$Res> implements $TimetableStateCo factory _$TimetableStateCopyWith(_TimetableState value, $Res Function(_TimetableState) _then) = __$TimetableStateCopyWithImpl; @override @useResult $Res call({ - Map weekCache, GetRoomsResponse? rooms, GetSubjectsResponse? subjects, GetHolidaysResponse? schoolHolidays, GetTimegridUnitsResponse? timegrid, GetCustomTimetableEventResponse? customEvents, DateTime startDate, DateTime endDate, int dataVersion + Map weekCache, GetRoomsResponse? rooms, GetSubjectsResponse? subjects, GetHolidaysResponse? schoolHolidays, GetCurrentSchoolyearResponse? schoolyear, GetTimegridUnitsResponse? timegrid, GetCustomTimetableEventResponse? customEvents, DateTime startDate, DateTime endDate, int dataVersion, DateTime? accessibleStartDate, DateTime? accessibleEndDate }); @@ -286,18 +300,21 @@ class __$TimetableStateCopyWithImpl<$Res> /// Create a copy of TimetableState /// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? weekCache = null,Object? rooms = freezed,Object? subjects = freezed,Object? schoolHolidays = freezed,Object? timegrid = freezed,Object? customEvents = freezed,Object? startDate = null,Object? endDate = null,Object? dataVersion = null,}) { +@override @pragma('vm:prefer-inline') $Res call({Object? weekCache = null,Object? rooms = freezed,Object? subjects = freezed,Object? schoolHolidays = freezed,Object? schoolyear = freezed,Object? timegrid = freezed,Object? customEvents = freezed,Object? startDate = null,Object? endDate = null,Object? dataVersion = null,Object? accessibleStartDate = freezed,Object? accessibleEndDate = freezed,}) { return _then(_TimetableState( weekCache: null == weekCache ? _self._weekCache : weekCache // ignore: cast_nullable_to_non_nullable as Map,rooms: freezed == rooms ? _self.rooms : rooms // ignore: cast_nullable_to_non_nullable as GetRoomsResponse?,subjects: freezed == subjects ? _self.subjects : subjects // ignore: cast_nullable_to_non_nullable as GetSubjectsResponse?,schoolHolidays: freezed == schoolHolidays ? _self.schoolHolidays : schoolHolidays // ignore: cast_nullable_to_non_nullable -as GetHolidaysResponse?,timegrid: freezed == timegrid ? _self.timegrid : timegrid // ignore: cast_nullable_to_non_nullable +as GetHolidaysResponse?,schoolyear: freezed == schoolyear ? _self.schoolyear : schoolyear // ignore: cast_nullable_to_non_nullable +as GetCurrentSchoolyearResponse?,timegrid: freezed == timegrid ? _self.timegrid : timegrid // ignore: cast_nullable_to_non_nullable as GetTimegridUnitsResponse?,customEvents: freezed == customEvents ? _self.customEvents : customEvents // ignore: cast_nullable_to_non_nullable as GetCustomTimetableEventResponse?,startDate: null == startDate ? _self.startDate : startDate // ignore: cast_nullable_to_non_nullable as DateTime,endDate: null == endDate ? _self.endDate : endDate // ignore: cast_nullable_to_non_nullable as DateTime,dataVersion: null == dataVersion ? _self.dataVersion : dataVersion // ignore: cast_nullable_to_non_nullable -as int, +as int,accessibleStartDate: freezed == accessibleStartDate ? _self.accessibleStartDate : accessibleStartDate // ignore: cast_nullable_to_non_nullable +as DateTime?,accessibleEndDate: freezed == accessibleEndDate ? _self.accessibleEndDate : accessibleEndDate // ignore: cast_nullable_to_non_nullable +as DateTime?, )); } diff --git a/lib/state/app/modules/timetable/bloc/timetable_state.g.dart b/lib/state/app/modules/timetable/bloc/timetable_state.g.dart index 367b428..0b60cae 100644 --- a/lib/state/app/modules/timetable/bloc/timetable_state.g.dart +++ b/lib/state/app/modules/timetable/bloc/timetable_state.g.dart @@ -29,6 +29,11 @@ _TimetableState _$TimetableStateFromJson(Map json) => : GetHolidaysResponse.fromJson( json['schoolHolidays'] as Map, ), + schoolyear: json['schoolyear'] == null + ? null + : GetCurrentSchoolyearResponse.fromJson( + json['schoolyear'] as Map, + ), timegrid: json['timegrid'] == null ? null : GetTimegridUnitsResponse.fromJson( @@ -42,6 +47,12 @@ _TimetableState _$TimetableStateFromJson(Map json) => startDate: DateTime.parse(json['startDate'] as String), endDate: DateTime.parse(json['endDate'] as String), dataVersion: (json['dataVersion'] as num?)?.toInt() ?? 0, + accessibleStartDate: json['accessibleStartDate'] == null + ? null + : DateTime.parse(json['accessibleStartDate'] as String), + accessibleEndDate: json['accessibleEndDate'] == null + ? null + : DateTime.parse(json['accessibleEndDate'] as String), ); Map _$TimetableStateToJson(_TimetableState instance) => @@ -50,9 +61,12 @@ Map _$TimetableStateToJson(_TimetableState instance) => 'rooms': instance.rooms, 'subjects': instance.subjects, 'schoolHolidays': instance.schoolHolidays, + 'schoolyear': instance.schoolyear, 'timegrid': instance.timegrid, 'customEvents': instance.customEvents, 'startDate': instance.startDate.toIso8601String(), 'endDate': instance.endDate.toIso8601String(), 'dataVersion': instance.dataVersion, + 'accessibleStartDate': instance.accessibleStartDate?.toIso8601String(), + 'accessibleEndDate': instance.accessibleEndDate?.toIso8601String(), }; diff --git a/lib/state/app/modules/timetable/data_provider/timetable_data_provider.dart b/lib/state/app/modules/timetable/data_provider/timetable_data_provider.dart index 2b029a2..ce4fff7 100644 --- a/lib/state/app/modules/timetable/data_provider/timetable_data_provider.dart +++ b/lib/state/app/modules/timetable/data_provider/timetable_data_provider.dart @@ -11,6 +11,8 @@ import '../../../../../api/mhsl/custom_timetable_event/remove/remove_custom_time import '../../../../../api/mhsl/custom_timetable_event/update/update_custom_timetable_event.dart'; import '../../../../../api/mhsl/custom_timetable_event/update/update_custom_timetable_event_params.dart'; import '../../../../../api/request_cache.dart'; +import '../../../../../api/webuntis/queries/get_current_schoolyear/get_current_schoolyear_cache.dart'; +import '../../../../../api/webuntis/queries/get_current_schoolyear/get_current_schoolyear_response.dart'; import '../../../../../api/webuntis/queries/get_holidays/get_holidays_cache.dart'; import '../../../../../api/webuntis/queries/get_holidays/get_holidays_response.dart'; import '../../../../../api/webuntis/queries/get_rooms/get_rooms_cache.dart'; @@ -73,6 +75,19 @@ class TimetableDataProvider { operationName: 'getSchoolHolidays', ); + Future getCurrentSchoolyear({ + void Function(Object)? onError, + bool renew = false, + }) => resolveFromCache( + (onUpdate, onError) => GetCurrentSchoolyearCache( + renew: renew, + onUpdate: onUpdate, + onError: onError, + ), + onError: onError, + operationName: 'getCurrentSchoolyear', + ); + Future getTimegrid({bool renew = false}) => resolveFromCache( (onUpdate, _) => diff --git a/lib/view/pages/timetable/timetable.dart b/lib/view/pages/timetable/timetable.dart index 6b7c8f3..8104ba0 100644 --- a/lib/view/pages/timetable/timetable.dart +++ b/lib/view/pages/timetable/timetable.dart @@ -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 { 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 { 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); + } } diff --git a/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart b/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart index b8f0a5c..978e610 100644 --- a/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart +++ b/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart @@ -86,6 +86,40 @@ class CustomWorkWeekCalendarState extends State { }); } + @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(); diff --git a/lib/view/pages/timetable/widgets/special_regions_builder.dart b/lib/view/pages/timetable/widgets/special_regions_builder.dart index 6fa073c..2a42175 100644 --- a/lib/view/pages/timetable/widgets/special_regions_builder.dart +++ b/lib/view/pages/timetable/widgets/special_regions_builder.dart @@ -62,33 +62,47 @@ class SpecialRegionsBuilder { static String _dayKey(DateTime d) => '${d.year}-${d.month}-${d.day}'; - Iterable _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.generate( - dayCount, - (i) => startDay.add(Duration(days: i)), - ); + Iterable _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 = {}; + 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 names; + _HolidayDay(this.day, this.names); +} diff --git a/lib/view/pages/timetable/widgets/time_region_tile.dart b/lib/view/pages/timetable/widgets/time_region_tile.dart index fc7dced..c9798a8 100644 --- a/lib/view/pages/timetable/widgets/time_region_tile.dart +++ b/lib/view/pages/timetable/widgets/time_region_tile.dart @@ -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,