From 646e2c04516f11576429d21a50ba784bfdf93be6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Wed, 5 Aug 2026 20:03:02 +0200 Subject: [PATCH] removed timetable limits, fully dependent on marianumConnect now --- lib/api/demo/data/demo_capabilities.dart | 5 +- .../get_capabilities_response.dart | 9 ++ .../get_capabilities_response.g.dart | 4 + .../capabilities/bloc/capabilities_cubit.dart | 6 ++ .../capabilities/bloc/capabilities_state.dart | 4 + .../bloc/capabilities_state.freezed.dart | 50 ++++++----- .../bloc/capabilities_state.g.dart | 4 + .../widgets/timetable_calendar_view.dart | 82 +++++++++++-------- 8 files changed, 109 insertions(+), 55 deletions(-) diff --git a/lib/api/demo/data/demo_capabilities.dart b/lib/api/demo/data/demo_capabilities.dart index d43bdf8..7e13043 100644 --- a/lib/api/demo/data/demo_capabilities.dart +++ b/lib/api/demo/data/demo_capabilities.dart @@ -2,13 +2,16 @@ import '../../../state/app/modules/capabilities/bloc/capabilities_state.dart'; import '../../../state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_state.dart'; /// Demo fixtures for the mobile capability flags — everything granted so the -/// demo persona sees every feature (incl. push) as available. +/// demo persona sees every feature (incl. push) as available and the timetable +/// scroll range stays unlimited (null day counts). class DemoCapabilities { const DemoCapabilities._(); static CapabilitiesState state() => const CapabilitiesState( viewForeignTimetables: true, pushNotifications: true, + timetablePastDays: null, + timetableFutureDays: null, loaded: true, ); } diff --git a/lib/api/marianumconnect/queries/get_capabilities/get_capabilities_response.dart b/lib/api/marianumconnect/queries/get_capabilities/get_capabilities_response.dart index f28a864..b11af56 100644 --- a/lib/api/marianumconnect/queries/get_capabilities/get_capabilities_response.dart +++ b/lib/api/marianumconnect/queries/get_capabilities/get_capabilities_response.dart @@ -16,9 +16,18 @@ class CapabilitiesResponse { @JsonKey(defaultValue: false) final bool pushNotifications; + /// How many days into the past/future the user may view the timetable. + /// `null` (absent) means unlimited — the school year alone governs. The + /// backend widens both to at least cover the current Mon–Sun week. + final int? timetablePastDays; + + final int? timetableFutureDays; + CapabilitiesResponse({ required this.viewForeignTimetables, required this.pushNotifications, + this.timetablePastDays, + this.timetableFutureDays, }); factory CapabilitiesResponse.fromJson(Map json) => diff --git a/lib/api/marianumconnect/queries/get_capabilities/get_capabilities_response.g.dart b/lib/api/marianumconnect/queries/get_capabilities/get_capabilities_response.g.dart index 3a7096a..7257827 100644 --- a/lib/api/marianumconnect/queries/get_capabilities/get_capabilities_response.g.dart +++ b/lib/api/marianumconnect/queries/get_capabilities/get_capabilities_response.g.dart @@ -11,6 +11,8 @@ CapabilitiesResponse _$CapabilitiesResponseFromJson( ) => CapabilitiesResponse( viewForeignTimetables: json['viewForeignTimetables'] as bool? ?? false, pushNotifications: json['pushNotifications'] as bool? ?? false, + timetablePastDays: (json['timetablePastDays'] as num?)?.toInt(), + timetableFutureDays: (json['timetableFutureDays'] as num?)?.toInt(), ); Map _$CapabilitiesResponseToJson( @@ -18,4 +20,6 @@ Map _$CapabilitiesResponseToJson( ) => { 'viewForeignTimetables': instance.viewForeignTimetables, 'pushNotifications': instance.pushNotifications, + 'timetablePastDays': instance.timetablePastDays, + 'timetableFutureDays': instance.timetableFutureDays, }; diff --git a/lib/state/app/modules/capabilities/bloc/capabilities_cubit.dart b/lib/state/app/modules/capabilities/bloc/capabilities_cubit.dart index bd76d13..20ad5a0 100644 --- a/lib/state/app/modules/capabilities/bloc/capabilities_cubit.dart +++ b/lib/state/app/modules/capabilities/bloc/capabilities_cubit.dart @@ -17,6 +17,10 @@ class CapabilitiesCubit extends HydratedCubit { bool get canReceivePushNotifications => state.pushNotifications; + int? get timetablePastDays => state.timetablePastDays; + + int? get timetableFutureDays => state.timetableFutureDays; + /// Refreshes capabilities from the server. On any failure (endpoint not yet /// live, network error, 4xx) the previously hydrated flags are kept but the /// state is marked `loaded` — a failed fetch never silently grants a @@ -32,6 +36,8 @@ class CapabilitiesCubit extends HydratedCubit { CapabilitiesState( viewForeignTimetables: response.viewForeignTimetables, pushNotifications: response.pushNotifications, + timetablePastDays: response.timetablePastDays, + timetableFutureDays: response.timetableFutureDays, loaded: true, ), ); diff --git a/lib/state/app/modules/capabilities/bloc/capabilities_state.dart b/lib/state/app/modules/capabilities/bloc/capabilities_state.dart index 7d9a75b..11a9f43 100644 --- a/lib/state/app/modules/capabilities/bloc/capabilities_state.dart +++ b/lib/state/app/modules/capabilities/bloc/capabilities_state.dart @@ -8,6 +8,10 @@ abstract class CapabilitiesState with _$CapabilitiesState { const factory CapabilitiesState({ @Default(false) bool viewForeignTimetables, @Default(false) bool pushNotifications, + // Days into the past/future the timetable may be scrolled. Null = no + // client-side clamp; the (server-narrowed) school year alone governs. + int? timetablePastDays, + int? timetableFutureDays, // Whether a capability response (or a definitive failure) has been // observed at least once this session. Lets the UI distinguish "still // unknown" from "confirmed not allowed". diff --git a/lib/state/app/modules/capabilities/bloc/capabilities_state.freezed.dart b/lib/state/app/modules/capabilities/bloc/capabilities_state.freezed.dart index ba5903f..64dcb7e 100644 --- a/lib/state/app/modules/capabilities/bloc/capabilities_state.freezed.dart +++ b/lib/state/app/modules/capabilities/bloc/capabilities_state.freezed.dart @@ -15,7 +15,9 @@ T _$identity(T value) => value; /// @nodoc mixin _$CapabilitiesState { - bool get viewForeignTimetables; bool get pushNotifications;// Whether a capability response (or a definitive failure) has been + bool get viewForeignTimetables; bool get pushNotifications;// Days into the past/future the timetable may be scrolled. Null = no +// client-side clamp; the (server-narrowed) school year alone governs. + int? get timetablePastDays; int? get timetableFutureDays;// Whether a capability response (or a definitive failure) has been // observed at least once this session. Lets the UI distinguish "still // unknown" from "confirmed not allowed". bool get loaded; @@ -31,16 +33,16 @@ $CapabilitiesStateCopyWith get copyWith => _$CapabilitiesStat @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is CapabilitiesState&&(identical(other.viewForeignTimetables, viewForeignTimetables) || other.viewForeignTimetables == viewForeignTimetables)&&(identical(other.pushNotifications, pushNotifications) || other.pushNotifications == pushNotifications)&&(identical(other.loaded, loaded) || other.loaded == loaded)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is CapabilitiesState&&(identical(other.viewForeignTimetables, viewForeignTimetables) || other.viewForeignTimetables == viewForeignTimetables)&&(identical(other.pushNotifications, pushNotifications) || other.pushNotifications == pushNotifications)&&(identical(other.timetablePastDays, timetablePastDays) || other.timetablePastDays == timetablePastDays)&&(identical(other.timetableFutureDays, timetableFutureDays) || other.timetableFutureDays == timetableFutureDays)&&(identical(other.loaded, loaded) || other.loaded == loaded)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,viewForeignTimetables,pushNotifications,loaded); +int get hashCode => Object.hash(runtimeType,viewForeignTimetables,pushNotifications,timetablePastDays,timetableFutureDays,loaded); @override String toString() { - return 'CapabilitiesState(viewForeignTimetables: $viewForeignTimetables, pushNotifications: $pushNotifications, loaded: $loaded)'; + return 'CapabilitiesState(viewForeignTimetables: $viewForeignTimetables, pushNotifications: $pushNotifications, timetablePastDays: $timetablePastDays, timetableFutureDays: $timetableFutureDays, loaded: $loaded)'; } @@ -51,7 +53,7 @@ abstract mixin class $CapabilitiesStateCopyWith<$Res> { factory $CapabilitiesStateCopyWith(CapabilitiesState value, $Res Function(CapabilitiesState) _then) = _$CapabilitiesStateCopyWithImpl; @useResult $Res call({ - bool viewForeignTimetables, bool pushNotifications, bool loaded + bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, bool loaded }); @@ -68,11 +70,13 @@ class _$CapabilitiesStateCopyWithImpl<$Res> /// Create a copy of CapabilitiesState /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? viewForeignTimetables = null,Object? pushNotifications = null,Object? loaded = null,}) { +@pragma('vm:prefer-inline') @override $Res call({Object? viewForeignTimetables = null,Object? pushNotifications = null,Object? timetablePastDays = freezed,Object? timetableFutureDays = freezed,Object? loaded = null,}) { return _then(_self.copyWith( viewForeignTimetables: null == viewForeignTimetables ? _self.viewForeignTimetables : viewForeignTimetables // ignore: cast_nullable_to_non_nullable as bool,pushNotifications: null == pushNotifications ? _self.pushNotifications : pushNotifications // ignore: cast_nullable_to_non_nullable -as bool,loaded: null == loaded ? _self.loaded : loaded // ignore: cast_nullable_to_non_nullable +as bool,timetablePastDays: freezed == timetablePastDays ? _self.timetablePastDays : timetablePastDays // ignore: cast_nullable_to_non_nullable +as int?,timetableFutureDays: freezed == timetableFutureDays ? _self.timetableFutureDays : timetableFutureDays // ignore: cast_nullable_to_non_nullable +as int?,loaded: null == loaded ? _self.loaded : loaded // ignore: cast_nullable_to_non_nullable as bool, )); } @@ -158,10 +162,10 @@ return $default(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen(TResult Function( bool viewForeignTimetables, bool pushNotifications, bool loaded)? $default,{required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(TResult Function( bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, bool loaded)? $default,{required TResult orElse(),}) {final _that = this; switch (_that) { case _CapabilitiesState() when $default != null: -return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.loaded);case _: +return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.timetablePastDays,_that.timetableFutureDays,_that.loaded);case _: return orElse(); } @@ -179,10 +183,10 @@ return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.loaded /// } /// ``` -@optionalTypeArgs TResult when(TResult Function( bool viewForeignTimetables, bool pushNotifications, bool loaded) $default,) {final _that = this; +@optionalTypeArgs TResult when(TResult Function( bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, bool loaded) $default,) {final _that = this; switch (_that) { case _CapabilitiesState(): -return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.loaded);case _: +return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.timetablePastDays,_that.timetableFutureDays,_that.loaded);case _: throw StateError('Unexpected subclass'); } @@ -199,10 +203,10 @@ return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.loaded /// } /// ``` -@optionalTypeArgs TResult? whenOrNull(TResult? Function( bool viewForeignTimetables, bool pushNotifications, bool loaded)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(TResult? Function( bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, bool loaded)? $default,) {final _that = this; switch (_that) { case _CapabilitiesState() when $default != null: -return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.loaded);case _: +return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.timetablePastDays,_that.timetableFutureDays,_that.loaded);case _: return null; } @@ -214,11 +218,15 @@ return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.loaded @JsonSerializable() class _CapabilitiesState implements CapabilitiesState { - const _CapabilitiesState({this.viewForeignTimetables = false, this.pushNotifications = false, this.loaded = false}); + const _CapabilitiesState({this.viewForeignTimetables = false, this.pushNotifications = false, this.timetablePastDays, this.timetableFutureDays, this.loaded = false}); factory _CapabilitiesState.fromJson(Map json) => _$CapabilitiesStateFromJson(json); @override@JsonKey() final bool viewForeignTimetables; @override@JsonKey() final bool pushNotifications; +// Days into the past/future the timetable may be scrolled. Null = no +// client-side clamp; the (server-narrowed) school year alone governs. +@override final int? timetablePastDays; +@override final int? timetableFutureDays; // Whether a capability response (or a definitive failure) has been // observed at least once this session. Lets the UI distinguish "still // unknown" from "confirmed not allowed". @@ -237,16 +245,16 @@ Map toJson() { @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _CapabilitiesState&&(identical(other.viewForeignTimetables, viewForeignTimetables) || other.viewForeignTimetables == viewForeignTimetables)&&(identical(other.pushNotifications, pushNotifications) || other.pushNotifications == pushNotifications)&&(identical(other.loaded, loaded) || other.loaded == loaded)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is _CapabilitiesState&&(identical(other.viewForeignTimetables, viewForeignTimetables) || other.viewForeignTimetables == viewForeignTimetables)&&(identical(other.pushNotifications, pushNotifications) || other.pushNotifications == pushNotifications)&&(identical(other.timetablePastDays, timetablePastDays) || other.timetablePastDays == timetablePastDays)&&(identical(other.timetableFutureDays, timetableFutureDays) || other.timetableFutureDays == timetableFutureDays)&&(identical(other.loaded, loaded) || other.loaded == loaded)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,viewForeignTimetables,pushNotifications,loaded); +int get hashCode => Object.hash(runtimeType,viewForeignTimetables,pushNotifications,timetablePastDays,timetableFutureDays,loaded); @override String toString() { - return 'CapabilitiesState(viewForeignTimetables: $viewForeignTimetables, pushNotifications: $pushNotifications, loaded: $loaded)'; + return 'CapabilitiesState(viewForeignTimetables: $viewForeignTimetables, pushNotifications: $pushNotifications, timetablePastDays: $timetablePastDays, timetableFutureDays: $timetableFutureDays, loaded: $loaded)'; } @@ -257,7 +265,7 @@ abstract mixin class _$CapabilitiesStateCopyWith<$Res> implements $CapabilitiesS factory _$CapabilitiesStateCopyWith(_CapabilitiesState value, $Res Function(_CapabilitiesState) _then) = __$CapabilitiesStateCopyWithImpl; @override @useResult $Res call({ - bool viewForeignTimetables, bool pushNotifications, bool loaded + bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, bool loaded }); @@ -274,11 +282,13 @@ class __$CapabilitiesStateCopyWithImpl<$Res> /// Create a copy of CapabilitiesState /// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? viewForeignTimetables = null,Object? pushNotifications = null,Object? loaded = null,}) { +@override @pragma('vm:prefer-inline') $Res call({Object? viewForeignTimetables = null,Object? pushNotifications = null,Object? timetablePastDays = freezed,Object? timetableFutureDays = freezed,Object? loaded = null,}) { return _then(_CapabilitiesState( viewForeignTimetables: null == viewForeignTimetables ? _self.viewForeignTimetables : viewForeignTimetables // ignore: cast_nullable_to_non_nullable as bool,pushNotifications: null == pushNotifications ? _self.pushNotifications : pushNotifications // ignore: cast_nullable_to_non_nullable -as bool,loaded: null == loaded ? _self.loaded : loaded // ignore: cast_nullable_to_non_nullable +as bool,timetablePastDays: freezed == timetablePastDays ? _self.timetablePastDays : timetablePastDays // ignore: cast_nullable_to_non_nullable +as int?,timetableFutureDays: freezed == timetableFutureDays ? _self.timetableFutureDays : timetableFutureDays // ignore: cast_nullable_to_non_nullable +as int?,loaded: null == loaded ? _self.loaded : loaded // ignore: cast_nullable_to_non_nullable as bool, )); } diff --git a/lib/state/app/modules/capabilities/bloc/capabilities_state.g.dart b/lib/state/app/modules/capabilities/bloc/capabilities_state.g.dart index 266a000..3be0776 100644 --- a/lib/state/app/modules/capabilities/bloc/capabilities_state.g.dart +++ b/lib/state/app/modules/capabilities/bloc/capabilities_state.g.dart @@ -10,6 +10,8 @@ _CapabilitiesState _$CapabilitiesStateFromJson(Map json) => _CapabilitiesState( viewForeignTimetables: json['viewForeignTimetables'] as bool? ?? false, pushNotifications: json['pushNotifications'] as bool? ?? false, + timetablePastDays: (json['timetablePastDays'] as num?)?.toInt(), + timetableFutureDays: (json['timetableFutureDays'] as num?)?.toInt(), loaded: json['loaded'] as bool? ?? false, ); @@ -17,5 +19,7 @@ Map _$CapabilitiesStateToJson(_CapabilitiesState instance) => { 'viewForeignTimetables': instance.viewForeignTimetables, 'pushNotifications': instance.pushNotifications, + 'timetablePastDays': instance.timetablePastDays, + 'timetableFutureDays': instance.timetableFutureDays, 'loaded': instance.loaded, }; diff --git a/lib/view/pages/timetable/widgets/timetable_calendar_view.dart b/lib/view/pages/timetable/widgets/timetable_calendar_view.dart index 6918cbb..0a11865 100644 --- a/lib/view/pages/timetable/widgets/timetable_calendar_view.dart +++ b/lib/view/pages/timetable/widgets/timetable_calendar_view.dart @@ -4,6 +4,7 @@ import 'package:syncfusion_flutter_calendar/calendar.dart'; import '../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart'; import '../../../../extensions/date_time.dart'; +import '../../../../state/app/modules/capabilities/bloc/capabilities_cubit.dart'; import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../../state/app/modules/timetable/bloc/timetable_state.dart'; import '../../../../storage/timetable_settings.dart'; @@ -105,7 +106,12 @@ class TimetableCalendarViewState extends State { disabledColor: Theme.of(context).disabledColor, ).build(); - final (minDate, maxDate) = _scrollBounds(state); + final capabilities = context.watch(); + final (minDate, maxDate) = _scrollBounds( + state, + pastDays: capabilities.timetablePastDays, + futureDays: capabilities.timetableFutureDays, + ); return CustomWorkWeekCalendar( key: _calendarKey, @@ -122,17 +128,21 @@ class TimetableCalendarViewState extends State { ); } - /// Hard caps applied on top of whatever Webuntis would allow. Even if the - /// school year (or a stale persisted bound) would let the user scroll - /// further, we never expose more than this much around the current week. - static const int _maxWeeksBack = 4; - static const int _maxWeeksForward = 2; - /// Returns the (minDate, maxDate) the user is allowed to scroll between. - /// Starts from the Webuntis school year (or a tight window when that hasn't - /// loaded yet), tightens by anything the bloc has learned from past denials, - /// and finally clamps to a fixed window around today. - (DateTime, DateTime) _scrollBounds(TimetableState state) { + /// Starts from the (server-narrowed) Webuntis school year — or a tight window + /// when that hasn't loaded yet —, tightens by anything the bloc has learned + /// from past denials, and finally clamps to the window Connect grants via + /// [CapabilitiesCubit.timetablePastDays]/[CapabilitiesCubit.timetableFutureDays]. + /// + /// The capability clamp mirrors the server: `null` means unlimited (no client + /// clamp at all), and a given day count is widened to at least cover the + /// current Mon–Sun week — so the two windows always coincide and the clamp + /// can never invert. + (DateTime, DateTime) _scrollBounds( + TimetableState state, { + required int? pastDays, + required int? futureDays, + }) { final year = state.schoolyear; final DateTime baseMin; final DateTime baseMax; @@ -154,39 +164,43 @@ class TimetableCalendarViewState extends State { ? state.accessibleEndDate! : baseMax) : baseMax; - final todayMonday = _mondayOf(DateTime.now()); - final cappedMin = effectiveMin.isBefore( - todayMonday.subtractDays(_maxWeeksBack * 7), - ) - ? todayMonday.subtractDays(_maxWeeksBack * 7) + final today = _startOfDay(DateTime.now()); + final todayMonday = _mondayOf(today); + final currentWeekEnd = todayMonday.addDays(DateTime.daysPerWeek - 1); + final capMin = pastDays == null + ? null + : _earlier(today.subtractDays(pastDays), todayMonday); + final capMax = futureDays == null + ? null + : _later(today.addDays(futureDays), currentWeekEnd); + final cappedMin = capMin != null && effectiveMin.isBefore(capMin) + ? capMin : effectiveMin; - final cappedMax = effectiveMax.isAfter( - todayMonday.addDays(_maxWeeksForward * 7 + 6), - ) - ? todayMonday.addDays(_maxWeeksForward * 7 + 6) + final cappedMax = capMax != null && effectiveMax.isAfter(capMax) + ? capMax : effectiveMax; // When the resulting range does not cover the current week — the summer gap // between two school years, or a stale persisted bound — fall back to the - // full fixed window around today. Otherwise the PageView clamps the initial - // page to the last week before the holidays (hiding the "Schulfrei" region) - // and forward scrolling collapses to the current week only. - final currentWeekEnd = todayMonday.addDays(DateTime.daysPerWeek - 1); + // current week, widened to whatever the capabilities still allow. Otherwise + // the PageView clamps the initial page to the last week before the holidays + // (hiding the "Schulfrei" region) and forward scrolling collapses to the + // current week only. final outsideRange = cappedMax.isBefore(todayMonday) || cappedMin.isAfter(currentWeekEnd); - final finalMin = outsideRange - ? todayMonday.subtractDays(_maxWeeksBack * 7) - : cappedMin; - final finalMax = outsideRange - ? todayMonday.addDays(_maxWeeksForward * 7 + 6) - : cappedMax; + final finalMin = outsideRange ? (capMin ?? todayMonday) : cappedMin; + final finalMax = outsideRange ? (capMax ?? currentWeekEnd) : cappedMax; final daysToMonday = (DateTime.monday - finalMin.weekday) % DateTime.daysPerWeek; final mondayMin = finalMin.addDays(daysToMonday); return (mondayMin, finalMax); } - static DateTime _mondayOf(DateTime d) { - final monday = d.subtractDays(d.weekday - 1); - return DateTime(monday.year, monday.month, monday.day); - } + 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; }