improved performance and battery usage on older devices
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
+42
-32
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
+57
-18
@@ -6,6 +6,7 @@ import 'package:hydrated_bloc/hydrated_bloc.dart';
|
||||
import '../../../../../api/errors/error_mapper.dart';
|
||||
import '../../../../../api/errors/stale_session_exception.dart';
|
||||
import '../../../../../model/account_data.dart';
|
||||
import '../../../../../utils/session_single_flight.dart';
|
||||
import '../../loadable_state/loadable_state.dart';
|
||||
import '../../loadable_state/loading_error.dart';
|
||||
import '../../repository/repository.dart';
|
||||
@@ -20,6 +21,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(
|
||||
@@ -113,6 +120,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>());
|
||||
}
|
||||
@@ -122,10 +131,8 @@ abstract class LoadableHydratedBloc<
|
||||
/// 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: AccountData().sessionEpoch},
|
||||
);
|
||||
R runInSession<R>(R Function() body) =>
|
||||
runZoned(body, zoneValues: {_sessionKey: AccountData().sessionEpoch});
|
||||
|
||||
@override
|
||||
void add(LoadableHydratedBlocEvent<TState> event) {
|
||||
@@ -160,20 +167,31 @@ abstract class LoadableHydratedBloc<
|
||||
);
|
||||
}
|
||||
|
||||
// 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() {
|
||||
log('Fetching data for ${TState.toString()}');
|
||||
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!');
|
||||
}),
|
||||
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!');
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -191,13 +209,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,
|
||||
@@ -205,6 +237,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();
|
||||
|
||||
|
||||
@@ -59,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;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:flutter_app_badge/flutter_app_badge.dart';
|
||||
@@ -6,6 +7,7 @@ import 'package:flutter_app_badge/flutter_app_badge.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';
|
||||
@@ -74,13 +76,32 @@ class ChatListBloc
|
||||
renew: renew,
|
||||
onError: (e) => capturedError = e,
|
||||
);
|
||||
_lastRoomsJson = null;
|
||||
add(DataGathered((s) => s.copyWith(rooms: rooms)));
|
||||
_updateAppBadge(rooms);
|
||||
|
||||
if (capturedError != null) throw capturedError!;
|
||||
}
|
||||
|
||||
Future<void> refresh({bool renew = true, bool silent = false}) async {
|
||||
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}) =>
|
||||
_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 {
|
||||
@@ -88,6 +109,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) {
|
||||
@@ -96,6 +122,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);
|
||||
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 {
|
||||
@@ -171,6 +214,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);
|
||||
}
|
||||
|
||||
@@ -142,12 +142,11 @@ class ForeignTimetableBloc
|
||||
|
||||
add(
|
||||
Emit(
|
||||
(s) => s.copyWith(
|
||||
(s) => s.withReferenceData(
|
||||
rooms: rooms,
|
||||
subjects: subjects,
|
||||
schoolHolidays: schoolHolidays,
|
||||
schoolyear: schoolyear,
|
||||
dataVersion: s.dataVersion + 1,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -157,11 +156,7 @@ class ForeignTimetableBloc
|
||||
|
||||
try {
|
||||
final timegrid = await repo.data.getTimegrid();
|
||||
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.
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,6 +11,8 @@ 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(DefaultSettings.get());
|
||||
|
||||
@@ -34,21 +37,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;
|
||||
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;
|
||||
|
||||
@@ -133,11 +133,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(
|
||||
@@ -177,12 +173,11 @@ class TimetableBloc
|
||||
|
||||
add(
|
||||
Emit(
|
||||
(s) => s.copyWith(
|
||||
(s) => s.withReferenceData(
|
||||
rooms: rooms,
|
||||
subjects: subjects,
|
||||
schoolHolidays: schoolHolidays,
|
||||
schoolyear: schoolyear,
|
||||
dataVersion: s.dataVersion + 1,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -192,11 +187,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.
|
||||
}
|
||||
@@ -212,12 +203,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);
|
||||
}
|
||||
@@ -225,11 +211,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) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import '../../../../../api/marianumconnect/queries/timetable_get_subjects/timeta
|
||||
import '../../../../../api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid_response.dart';
|
||||
import '../../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
|
||||
import '../../../../../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart';
|
||||
import '../../../../../utils/json_equality.dart';
|
||||
import 'week_cache.dart';
|
||||
|
||||
part 'timetable_state.freezed.dart';
|
||||
@@ -58,6 +59,45 @@ abstract class TimetableState with _$TimetableState {
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
bool get hasReferenceData =>
|
||||
rooms != null &&
|
||||
subjects != null &&
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import 'package:collection/collection.dart';
|
||||
|
||||
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 = 4;
|
||||
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
|
||||
@@ -21,10 +20,7 @@ Map<String, TimetableGetWeekResponse>? mergeWeekIntoCache(
|
||||
}) {
|
||||
final key = weekStart.weekKey();
|
||||
final existing = cache[key];
|
||||
if (existing != null &&
|
||||
const DeepCollectionEquality().equals(existing.toJson(), week.toJson())) {
|
||||
return null;
|
||||
}
|
||||
if (sameJson(existing, week)) return null;
|
||||
|
||||
final viewedMonday = viewedWeekStart.mondayOfWeek;
|
||||
final todayMonday = now.mondayOfWeek;
|
||||
|
||||
Reference in New Issue
Block a user