merged develop

This commit is contained in:
2026-09-27 14:58:07 +02:00
188 changed files with 7474 additions and 2558 deletions
@@ -20,10 +20,15 @@ class LoadableStateBloc extends Bloc<LoadableStateEvent, LoadableStateState>
LoadableStateBloc() : super(const LoadableStateState(connections: null)) {
on<ConnectivityChanged>((event, emit) {
// Only a real reconnect (or a resume) warrants a refetch: the initial
// status after mount and Wi-Fi ↔ mobile handovers would otherwise
// reload an already loaded page for nothing.
final wasOffline = connectivityStatusKnown() && !isConnected();
emit(event.state);
if (connectivityStatusKnown() && isConnected()) {
if (reFetch == null) return;
reFetch!();
if ((wasOffline || event.fromResume) &&
connectivityStatusKnown() &&
isConnected()) {
reFetch?.call();
}
});
@@ -54,7 +59,12 @@ class LoadableStateBloc extends Bloc<LoadableStateEvent, LoadableStateState>
unawaited(
Connectivity().checkConnectivity().then((result) {
if (isClosed) return;
add(ConnectivityChanged(LoadableStateState(connections: result)));
add(
ConnectivityChanged(
LoadableStateState(connections: result),
fromResume: true,
),
);
}),
);
}
@@ -4,5 +4,10 @@ sealed class LoadableStateEvent {}
final class ConnectivityChanged extends LoadableStateEvent {
final LoadableStateState state;
ConnectivityChanged(this.state);
/// Re-check after the app came back to the foreground: refetches whenever
/// online, not only on an offline → online transition.
final bool fromResume;
ConnectivityChanged(this.state, {this.fromResume = false});
}
@@ -69,7 +69,7 @@ class LoadableStateConsumer<
// null mid-refetch, and toggling the RefreshIndicator on that signal would
// rebuild the tree under the ListView and reset its scroll position.
final content = SizedBox(
height: MediaQuery.of(context).size.height,
height: MediaQuery.sizeOf(context).height,
child: hasContent
? child(typedData as TState, isLoading)
: const SizedBox.shrink(),
@@ -95,10 +95,12 @@ class _LoadableStateErrorBarTextState extends State<LoadableStateErrorBarText> {
late Timer _rebuildTimer;
@override
void initState() {
_rebuildTimer = Timer.periodic(
const Duration(seconds: 10),
(timer) => setState(() {}),
);
// Only refresh the relative "last updated" text while this page is the
// visible one; offstage tabs and covered routes have tickers disabled.
_rebuildTimer = Timer.periodic(const Duration(seconds: 10), (timer) {
if (!mounted) return;
if (TickerMode.getValuesNotifier(context).value.enabled) setState(() {});
});
super.initState();
}
@@ -33,6 +33,9 @@ class _LoadableStatePrimaryLoadingState
extends State<LoadableStatePrimaryLoading> {
Timer? _slowHintTimer;
bool _showSlowHint = false;
// An indeterminate spinner ticks every frame even at opacity 0, so it is
// unmounted once the fade-out finished instead of just being hidden.
late bool _spinnerMounted = widget.visible;
@override
void initState() {
@@ -44,6 +47,7 @@ class _LoadableStatePrimaryLoadingState
void didUpdateWidget(covariant LoadableStatePrimaryLoading oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.visible != oldWidget.visible) _restartSlowHintTimer();
if (widget.visible) _spinnerMounted = true;
}
void _restartSlowHintTimer() {
@@ -62,43 +66,49 @@ class _LoadableStatePrimaryLoadingState
}
@override
Widget build(BuildContext context) {
Widget build(BuildContext context) => AnimatedOpacity(
opacity: widget.visible ? 1.0 : 0.0,
duration: LoadableStateConsumer.animationDuration,
curve: Curves.easeInOut,
onEnd: () {
if (!widget.visible && _spinnerMounted) {
setState(() => _spinnerMounted = false);
}
},
child: _spinnerMounted ? _spinner(context) : const SizedBox.shrink(),
);
Widget _spinner(BuildContext context) {
final status =
widget.statusText ??
(_showSlowHint ? LoadableStatePrimaryLoading.slowHintText : null);
return AnimatedOpacity(
opacity: widget.visible ? 1.0 : 0.0,
duration: LoadableStateConsumer.animationDuration,
curve: Curves.easeInOut,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const AppProgressIndicator.large(),
AnimatedSwitcher(
duration: LoadableStateConsumer.animationDuration,
child: status == null
? const SizedBox.shrink()
: Padding(
key: ValueKey(status),
padding: const EdgeInsets.only(
top: 16,
left: 24,
right: 24,
),
child: Text(
status,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
color: Theme.of(context).hintColor,
),
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const AppProgressIndicator.large(),
AnimatedSwitcher(
duration: LoadableStateConsumer.animationDuration,
child: status == null
? const SizedBox.shrink()
: Padding(
key: ValueKey(status),
padding: const EdgeInsets.only(
top: 16,
left: 24,
right: 24,
),
child: Text(
status,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
color: Theme.of(context).hintColor,
),
),
),
],
),
),
),
],
),
);
}
@@ -1,10 +1,13 @@
import 'dart:async';
import 'dart:developer';
import 'package:hydrated_bloc/hydrated_bloc.dart';
import '../../../../../access/access_requirement.dart';
import '../../../../../api/errors/error_mapper.dart';
import '../../../../../api/errors/stale_session_exception.dart';
import '../../../../../session/session_manager.dart';
import '../../../../../utils/session_single_flight.dart';
import '../../loadable_state/loadable_state.dart';
import '../../loadable_state/loading_error.dart';
import '../../repository/repository.dart';
@@ -19,6 +22,12 @@ abstract class LoadableHydratedBloc<
extends
HydratedBloc<LoadableHydratedBlocEvent<TState>, LoadableState<TState>> {
late TRepository _repository;
// HydratedBloc serialises and writes the full state on every emit; loading
// flags, status texts and errors re-emit the very same data, so those
// writes are skipped (see [persistenceKey]).
Object? _lastPersistedKey;
int? _lastPersistedFetch;
LoadableHydratedBloc()
: super(
const LoadableState(
@@ -112,6 +121,8 @@ abstract class LoadableHydratedBloc<
/// fresh [fetch] (e.g. via [retry] or page-specific refresh) once the user
/// is authenticated again, otherwise the UI would stay blank.
Future<void> reset() async {
_lastPersistedKey = null;
_lastPersistedFetch = null;
await clear();
add(Reset<TState>());
}
@@ -123,6 +134,30 @@ abstract class LoadableHydratedBloc<
bool get requirementsMet => requirements.areMetBy(SessionManager().current);
// Blocs are rebuilt per account (see AccountScope). One that briefly
// outlives its account would otherwise load with the next account's
// credentials and persist the result into its own storage.
final int _ownSession = SessionManager().sessionEpoch;
/// Whether the account this bloc was created for is still the active one.
bool get ownsSession => SessionManager().isCurrentSession(_ownSession);
static const _sessionKey = #loadableSessionEpoch;
/// Runs [body] tagged with the current session: events it adds, also from
/// its async continuations, are dropped once the account signed out, so a
/// late response of the previous account cannot refill the reset bloc.
R runInSession<R>(R Function() body) =>
runZoned(body, zoneValues: {_sessionKey: SessionManager().sessionEpoch});
@override
void add(LoadableHydratedBlocEvent<TState> event) {
if (!ownsSession) return;
final epoch = Zone.current[_sessionKey];
if (epoch is int && !SessionManager().isCurrentSession(epoch)) return;
super.add(event);
}
TState? get innerState => state.data;
TRepository get repo => _repository;
@@ -135,30 +170,47 @@ abstract class LoadableHydratedBloc<
/// Maps [e] through the shared error mapper and emits it as an [Error] event.
/// Does not guard [isClosed] — callers decide whether a late error still
/// applies.
void addLoadingError(Object e) => add(
Error(
LoadingError(
message: errorToUserMessage(e),
technicalDetails: errorToTechnicalDetails(e),
allowRetry: errorAllowsRetry(e),
void addLoadingError(Object e) {
// Belongs to a signed-out account; the current one loads on its own.
if (e is StaleSessionException) return;
add(
Error(
LoadingError(
message: errorToUserMessage(e),
technicalDetails: errorToTechnicalDetails(e),
allowRetry: errorAllowsRetry(e),
),
),
),
);
);
}
// The constructor, the app shell and resume/reconnect handlers can all ask
// at once; each parallel gather would re-parse, re-emit and re-persist the
// same data.
final SessionSingleFlight _fetchFlight = SessionSingleFlight();
void fetch() {
if (!requirementsMet) return;
log('Fetching data for ${TState.toString()}');
gatherData()
.catchError((Object e) {
log('Error while fetching ${TState.toString()}: ${e.toString()}');
// The bloc may have been closed before this async error landed;
// adding to a closed bloc throws, so swallow that case.
if (isClosed) return;
addLoadingError(e);
})
.then((value) {
log('Fetch for ${TState.toString()} completed!');
});
if (!requirementsMet || !ownsSession) return;
unawaited(
_fetchFlight.run(() {
log('Fetching data for ${TState.toString()}');
return runInSession(
() => gatherData()
.catchError((Object e) {
log(
'Error while fetching ${TState.toString()}: ${e.toString()}',
);
// The bloc may have been closed before this async error
// landed; adding to a closed bloc throws, so swallow that case.
if (isClosed) return;
addLoadingError(e);
})
.then((value) {
log('Fetch for ${TState.toString()} completed!');
}),
);
}),
);
}
@override
@@ -175,13 +227,27 @@ abstract class LoadableHydratedBloc<
@override
Map<String, dynamic>? toJson(LoadableState<TState> state) {
final stateData = state.data;
final key = stateData is TState ? persistenceKey(stateData) : null;
if (key != null &&
// Identity for plain data: a freezed `==` would deep-compare it.
(identical(key, _lastPersistedKey) ||
(key is Record && key == _lastPersistedKey)) &&
state.lastFetch == _lastPersistedFetch) {
return null;
}
_lastPersistedKey = key;
_lastPersistedFetch = state.lastFetch;
Map<String, dynamic>? data;
try {
final stateData = state.data;
data = stateData is TState ? toStorage(stateData) : null;
} catch (e) {
log('Failed to save state ${TState.toString()}: ${e.toString()}');
}
// Blocs that keep nothing on disk return null from toStorage; writing an
// empty wrapper per emit would be pure overhead.
if (stateData != null && data == null) return null;
return LoadableSaveContext.wrap(
data,
@@ -189,6 +255,13 @@ abstract class LoadableHydratedBloc<
);
}
/// What has to change for the state to be written to disk again. Defaults
/// to the data instance; blocs whose state also carries transient UI flags
/// can return a record of just the persisted parts (records compare with
/// `==`, so use fields that compare cheaply, e.g. by identity).
Object? persistenceKey(TState data) => data;
Future<void> gatherData();
TRepository repository();
+21
View File
@@ -199,6 +199,27 @@ class AppModule {
return order;
}
/// Modules that are of no use to teachers and start out hidden for them.
static const Set<Modules> hiddenForTeachersByDefault = {
Modules.absenceReport,
};
/// Hides [hiddenForTeachersByDefault] once for teacher accounts; the teacher
/// can re-enable them in the module settings. Returns whether [settings]
/// changed.
static bool applyTeacherDefaults(
ModulesSettings settings, {
required bool isTeacher,
}) {
if (!isTeacher || settings.teacherDefaultsApplied) return false;
settings.hiddenModules = {
...settings.hiddenModules,
...hiddenForTeachersByDefault,
}.toList();
settings.teacherDefaultsApplied = true;
return true;
}
// The settings list displays a capability-filtered subset, so reorder
// indices refer to that subset; the move is applied there and merged back
// into the full order (non-displayed modules keep their relative slots).
@@ -37,8 +37,12 @@ class CapabilitiesCubit extends HydratedCubit<CapabilitiesState> {
);
return;
}
final epoch = SessionManager().sessionEpoch;
try {
final response = await GetCapabilities().run();
// A slow answer for the previous account must not decide the modules
// of the next one.
if (!SessionManager().isCurrentSession(epoch)) return;
emit(
CapabilitiesState(
viewForeignTimetables: response.viewForeignTimetables,
@@ -56,7 +60,7 @@ class CapabilitiesCubit extends HydratedCubit<CapabilitiesState> {
}
}
void reset() => emit(const CapabilitiesState());
Future<void> reset() async => emit(const CapabilitiesState());
@override
CapabilitiesState fromJson(Map<String, dynamic> json) {
+25 -5
View File
@@ -23,6 +23,9 @@ class ChatBloc
final ChatListBloc? _chatListBloc;
String? _pollingToken;
// A loop parked in its request survives stop+start for the same token
// (pause/resume); the generation retires it so only one loop polls.
int _pollGeneration = 0;
int _backoffMs = 0;
int _lastKnownMessageId = 0;
bool _appResumed = true;
@@ -56,6 +59,12 @@ class ChatBloc
@override
ChatState fromStorage(Map<String, dynamic> json) => ChatState.fromJson(json);
// Loading-older and reply-reference flips must not re-persist up to 500
// messages; GetChatResponse has no `==`, so it compares by identity here.
@override
Object? persistenceKey(ChatState data) =>
(data.currentToken, data.chatResponse, data.hasMoreOld);
@override
Map<String, dynamic>? toStorage(ChatState state) {
final response = state.chatResponse;
@@ -123,6 +132,15 @@ class ChatBloc
/// No-op when the bloc has already moved on to a different token: when
/// popping a stacked chat (B over A), A's didPopNext runs setToken(A)
/// before B's dispose fires.
/// The chat view may still be popping when the sign-out resets this bloc,
/// so leaveChat would find no token and the long-poll would keep running.
@override
Future<void> reset() {
_chatViewActive = false;
_stopLongPoll();
return super.reset();
}
void leaveChat(String fromToken) {
if ((innerState?.currentToken ?? '') != fromToken) return;
_chatViewActive = false;
@@ -256,23 +274,25 @@ class ChatBloc
_pollingToken = token;
_backoffMs = 0;
_lastKnownMessageId = _maxMessageId(innerState?.chatResponse);
unawaited(_pollLoop(token));
unawaited(_pollLoop(token, ++_pollGeneration));
}
void _stopLongPoll() {
_pollingToken = null;
_pollGeneration++;
_backoffMs = 0;
}
Future<void> _pollLoop(String token) async {
while (_pollingToken == token && !isClosed) {
Future<void> _pollLoop(String token, int generation) async {
bool active() => generation == _pollGeneration && !isClosed;
while (active()) {
try {
final response = await LongPollChat(
chatToken: token,
lastKnownMessageId: _lastKnownMessageId,
).run();
if (_pollingToken != token || isClosed) return;
if (!active()) return;
_backoffMs = 0;
if (response == null) continue;
@@ -296,7 +316,7 @@ class ChatBloc
_chatListBloc?.markRoomAsRead(token, _lastKnownMessageId);
}
} on Object catch (e) {
if (_pollingToken != token || isClosed) return;
if (!active()) return;
log('LongPoll error for $token: $e');
_backoffMs = _backoffMs == 0 ? 2000 : math.min(_backoffMs * 2, 30000);
await Future.delayed(Duration(milliseconds: _backoffMs));
@@ -1,11 +1,14 @@
import 'dart:async';
import 'dart:convert';
import 'dart:developer';
import 'package:flutter_app_badge/flutter_app_badge.dart';
import '../../../../../access/access_requirement.dart';
import '../../../../../api/marianumcloud/talk/actions/talk_actions.dart';
import '../../../../../api/marianumcloud/talk/chat/get_chat_response.dart';
import '../../../../../api/marianumcloud/talk/room/get_room_response.dart';
import '../../../../../utils/session_single_flight.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/chat_list_repository.dart';
@@ -31,6 +34,13 @@ class ChatListBloc
return super.close();
}
// The timer outlives the app shell; the next shell re-arms it after login.
@override
Future<void> reset() {
setAutoRefreshInterval(null);
return super.reset();
}
/// Silent refresh — explicit pull-to-refresh and tab-activation are non-silent.
void setAutoRefreshInterval(Duration? interval) {
if (interval == _autoRefreshInterval) return;
@@ -72,14 +82,34 @@ class ChatListBloc
renew: renew,
onError: (e) => capturedError = e,
);
_lastRoomsJson = null;
add(DataGathered((s) => s.copyWith(rooms: rooms)));
_updateAppBadge(rooms);
if (capturedError != null) throw capturedError!;
}
final SessionSingleFlight _refreshFlight = SessionSingleFlight();
/// Concurrent callers (tab switch, poll, push, resume) join the running
/// refresh instead of each fetching and re-emitting the full room list.
Future<void> refresh({bool renew = true, bool silent = false}) async {
if (!requirementsMet) return;
if (!requirementsMet || !ownsSession) return;
await _refreshFlight.run(() => _refresh(renew: renew, silent: silent));
}
/// Skips the refresh when the list was fetched within [maxAge].
Future<void> refreshIfOlderThan(Duration maxAge) {
final lastFetch = state.lastFetch;
if (lastFetch != null &&
DateTime.now().millisecondsSinceEpoch - lastFetch <
maxAge.inMilliseconds) {
return Future.value();
}
return refresh();
}
Future<void> _refresh({required bool renew, required bool silent}) async {
if (!silent) add(RefetchStarted<ChatListState>());
Object? capturedError;
try {
@@ -87,6 +117,11 @@ class ChatListBloc
renew: renew,
onError: (e) => capturedError = e,
);
if (silent) {
if (_isUnchanged(rooms)) return;
} else {
_lastRoomsJson = null;
}
add(DataGathered((s) => s.copyWith(rooms: rooms)));
_updateAppBadge(rooms);
} catch (e) {
@@ -95,6 +130,23 @@ class ChatListBloc
if (capturedError != null) addLoadingError(capturedError!);
}
// Encoded room data of the last applied poll result; response headers are
// left out because they differ on every request.
String? _lastRoomsJson;
/// A background poll that returns the same rooms would otherwise rebuild the
/// list and re-persist the whole state every 15 s.
bool _isUnchanged(GetRoomResponse rooms) {
final encoded = jsonEncode(rooms.data.toList());
final unchanged =
encoded == _lastRoomsJson &&
innerState?.rooms != null &&
state.error == null &&
!state.isLoading;
_lastRoomsJson = encoded;
return unchanged;
}
/// Creates (or resolves) a 1:1 chat and returns its room token, or null in
/// demo mode. Refreshes the list so the room shows up.
Future<String?> createDirectChat(String invite) async {
@@ -126,6 +178,18 @@ class ChatListBloc
});
}
Future<void> setNotificationLevel(
String token,
GetRoomResponseObjectParticipantNotificationLevel level,
) async {
await SetNotificationLevel(token, level).run();
_mutateRoom(token, (r) {
if (r.notificationLevel == level) return false;
r.notificationLevel = level;
return true;
});
}
/// Clears unread too — long-poll only feeds this in for an actively-open chat.
void applyIncomingMessage(String token, GetChatResponseObject message) {
_mutateRoom(token, (r) {
@@ -158,6 +222,7 @@ class ChatListBloc
}).toSet();
if (!changed) return;
final newRooms = GetRoomResponse(updated)..headers = rooms.headers;
_lastRoomsJson = null;
add(Emit((s) => s.copyWith(rooms: newRooms)));
_updateAppBadge(newRooms);
}
@@ -1,5 +1,6 @@
import 'package:dio/dio.dart';
import 'package:enough_icalendar/enough_icalendar.dart';
import 'package:flutter/foundation.dart';
import '../bloc/marianum_dates_state.dart';
@@ -19,6 +20,12 @@ class MarianumDatesGetEvents {
final body = response.data;
if (body == null || body.isEmpty) return [];
// The public feed holds hundreds of events back to 1981; parsing it on
// the UI isolate froze the page for a noticeable moment on every open.
return compute(parseEvents, body);
}
static List<MarianumDate> parseEvents(String body) {
final root = VComponent.parse(body);
final calendar = root is VCalendar ? root : null;
final source = calendar?.children ?? root.children;
@@ -65,8 +65,12 @@ class NextcloudCapabilitiesCubit
emit(DemoNextcloudCapabilities.state());
return;
}
final epoch = SessionManager().sessionEpoch;
try {
final caps = await GetNextcloudCapabilities().run();
// A slow answer for the previous account must not decide the modules
// of the next one.
if (!SessionManager().isCurrentSession(epoch)) return;
emit(
NextcloudCapabilitiesState(
apiEnabled: caps.apiEnabled,
@@ -92,7 +96,7 @@ class NextcloudCapabilitiesCubit
}
}
void reset() => emit(const NextcloudCapabilitiesState());
Future<void> reset() async => emit(const NextcloudCapabilitiesState());
@override
NextcloudCapabilitiesState fromJson(Map<String, dynamic> json) {
@@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:developer';
import 'package:collection/collection.dart';
import 'package:hydrated_bloc/hydrated_bloc.dart';
import '../../../../../storage/settings.dart';
@@ -10,8 +11,15 @@ import '../../../../../view/pages/settings/data/default_settings.dart';
class SettingsCubit extends HydratedCubit<Settings> {
static const _debounceTag = 'settings_persist';
bool _emitScheduled = false;
Map<String, dynamic>? _lastEmittedJson;
Timer? _silentSave;
SettingsCubit({super.storage}) : super(DefaultSettings.get());
final Storage? _storage;
// ignore: use_super_parameters
SettingsCubit({Storage? storage})
: _storage = storage,
super(DefaultSettings.get(), storage: storage);
Settings val({bool write = false}) {
if (write) {
@@ -34,21 +42,53 @@ class SettingsCubit extends HydratedCubit<Settings> {
return state;
}
/// Persists in-place mutations without notifying listeners. For high-frequency
/// writes nobody renders live (chat drafts per keystroke): a regular write
/// would rebuild the app root and every settings watcher on each change.
void saveSilently() {
_silentSave?.cancel();
_silentSave = Timer(const Duration(milliseconds: 800), flushSilentSave);
}
/// Writes a pending [saveSilently] right away (e.g. when the app pauses).
void flushSilentSave() {
if (_silentSave == null) return;
_silentSave!.cancel();
_silentSave = null;
(_storage ?? HydratedBloc.storage).write(storageToken, state.toJson());
}
void _emitFreshInstance() {
try {
emit(Settings.fromJson(state.toJson()));
final json = state.toJson();
// The debounced emit usually follows the microtask emit with identical
// content; skip it instead of rebuilding every listener a second time.
if (const DeepCollectionEquality().equals(json, _lastEmittedJson)) {
return;
}
_lastEmittedJson = json;
emit(Settings.fromJson(json));
} catch (e) {
log('Failed to refresh settings state: $e');
}
}
Future<void> reset() async {
_silentSave?.cancel();
_silentSave = null;
_lastEmittedJson = null;
emit(DefaultSettings.get());
}
// Modules missing from a stale persisted moduleOrder are handled at read
// time by AppModule.effectiveModuleOrder (inserted at their default
// position) — no healing on hydration needed.
@override
Future<void> close() {
flushSilentSave();
return super.close();
}
@override
Settings fromJson(Map<String, dynamic> json) {
try {
@@ -1,3 +1,8 @@
import 'dart:async';
import 'package:hydrated_bloc/hydrated_bloc.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../../../../api/errors/ticker_content_unavailable_exception.dart';
import '../../../../../api/marianumconnect/queries/get_ticker_page/get_ticker_page_response.dart';
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart';
@@ -17,7 +22,35 @@ class TickerPageBloc
> {
final String slug;
TickerPageBloc(this.slug);
TickerPageBloc(this.slug) {
unawaited(_rememberSlug());
}
static const _recentSlugsKey = 'tickerPageRecentSlugs';
static const _keepPages = 50;
/// Every page ever opened used to stay in the hydrated box forever — and
/// the whole box is decoded before the first frame on each cold start.
/// The most recently opened pages (far more than a normal reader revisits)
/// keep their offline copy; only long-forgotten ones are dropped.
Future<void> _rememberSlug() async {
try {
final prefs = await SharedPreferences.getInstance();
final recent = prefs.getStringList(_recentSlugsKey) ?? <String>[];
recent
..remove(slug)
..insert(0, slug);
for (final evicted in recent.skip(_keepPages)) {
await HydratedBloc.storage.delete('$storagePrefix$evicted');
}
await prefs.setStringList(
_recentSlugsKey,
recent.take(_keepPages).toList(),
);
} on Object {
// Best effort: a failed cleanup only leaves an extra offline copy.
}
}
@override
String get id => slug;
@@ -48,7 +48,7 @@ class TimetableBloc
TimetableState fromNothing() {
final reference = DateTime.now().addDays(2);
return TimetableState(
startDate: _startOfWeek(reference),
startDate: reference.mondayOfWeek,
endDate: _endOfWeek(reference),
);
}
@@ -61,7 +61,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,
@@ -120,13 +120,15 @@ class TimetableBloc
final current = innerState ?? fromNothing();
if (current.startDate == startDate && current.endDate == endDate) return;
add(Emit((s) => s.copyWith(startDate: startDate, endDate: endDate)));
_loadCurrentWeek(startDate, endDate);
_prefetchAdjacentWeeks(startDate, endDate);
runInSession(() {
_loadCurrentWeek(startDate, endDate);
_prefetchAdjacentWeeks(startDate, endDate);
});
}
void resetWeek() {
final reference = DateTime.now().addDays(2);
changeWeek(_startOfWeek(reference), _endOfWeek(reference));
changeWeek(reference.mondayOfWeek, _endOfWeek(reference));
}
void refresh() => fetch();
@@ -171,11 +173,7 @@ class TimetableBloc
Future<void> _refreshSubjects() async {
final subjects = await repo.data.getSubjects(renew: true);
add(
DataGathered(
(s) => s.copyWith(subjects: subjects, dataVersion: s.dataVersion + 1),
),
);
add(DataGathered((s) => s.withReferenceData(subjects: subjects)));
}
Future<void> _loadCurrentWeek(
@@ -214,12 +212,11 @@ class TimetableBloc
add(
Emit(
(s) => s.copyWith(
(s) => s.withReferenceData(
rooms: rooms,
subjects: subjects,
schoolHolidays: schoolHolidays,
schoolyear: schoolyear,
dataVersion: s.dataVersion + 1,
),
),
);
@@ -229,11 +226,7 @@ class TimetableBloc
try {
final timegrid = await repo.data.getTimegrid(renew: renew);
add(
Emit(
(s) => s.copyWith(timegrid: timegrid, dataVersion: s.dataVersion + 1),
),
);
add(Emit((s) => s.withReferenceData(timegrid: timegrid)));
} catch (_) {
// Timegrid load failure falls back to a hardcoded schedule in the UI layer.
}
@@ -249,12 +242,7 @@ class TimetableBloc
renew: renew,
onError: onError,
);
add(
Emit(
(s) =>
s.copyWith(customEvents: events, dataVersion: s.dataVersion + 1),
),
);
add(Emit((s) => s.withReferenceData(customEvents: events)));
} catch (e) {
onError?.call(e);
}
@@ -262,11 +250,7 @@ class TimetableBloc
Future<void> _refreshCustomEvents() async {
final events = await repo.data.getCustomEvents(renew: true);
add(
DataGathered(
(s) => s.copyWith(customEvents: events, dataVersion: s.dataVersion + 1),
),
);
add(DataGathered((s) => s.withReferenceData(customEvents: events)));
}
void _prefetchAdjacentWeeks(DateTime start, DateTime end) {
@@ -282,19 +266,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,8 @@ 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 '../../../../../utils/json_equality.dart';
import 'week_cache.dart';
part 'timetable_state.freezed.dart';
part 'timetable_state.g.dart';
@@ -40,6 +42,62 @@ 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);
}
/// Applies freshly loaded reference data. Values whose content equals the
/// current one keep the old instance, and when nothing changed at all this
/// returns `this` — every `dataVersion` bump and new identity makes the
/// calendar rebuild all appointments and break regions.
TimetableState withReferenceData({
TimetableGetRoomsResponse? rooms,
TimetableGetSubjectsResponse? subjects,
TimetableGetHolidaysResponse? schoolHolidays,
TimetableGetSchoolyearResponse? schoolyear,
TimetableGetTimegridResponse? timegrid,
GetCustomTimetableEventResponse? customEvents,
}) {
T? changed<T>(T? next, T? current) =>
next == null || sameJson(next, current) ? null : next;
final newRooms = changed(rooms, this.rooms);
final newSubjects = changed(subjects, this.subjects);
final newHolidays = changed(schoolHolidays, this.schoolHolidays);
final newSchoolyear = changed(schoolyear, this.schoolyear);
final newTimegrid = changed(timegrid, this.timegrid);
final newCustomEvents = changed(customEvents, this.customEvents);
if (newRooms == null &&
newSubjects == null &&
newHolidays == null &&
newSchoolyear == null &&
newTimegrid == null &&
newCustomEvents == null) {
return this;
}
return copyWith(
rooms: newRooms ?? this.rooms,
subjects: newSubjects ?? this.subjects,
schoolHolidays: newHolidays ?? this.schoolHolidays,
schoolyear: newSchoolyear ?? this.schoolyear,
timegrid: newTimegrid ?? this.timegrid,
customEvents: newCustomEvents ?? this.customEvents,
dataVersion: dataVersion + 1,
);
}
/// Whether the calendar has everything it needs to render. Custom events
/// only exist for subjects that support them; requiring them elsewhere would
/// keep foreign plans loading forever.
@@ -0,0 +1,41 @@
import '../../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
import '../../../../../extensions/date_time.dart';
import '../../../../../utils/json_equality.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 = 8;
/// 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 (sameJson(existing, week)) 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;
}