improved performance and battery usage on older devices

This commit is contained in:
2026-09-25 23:58:08 +02:00
parent ed8e52f475
commit 56a497985b
70 changed files with 1631 additions and 621 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,
),
),
),
],
),
),
),
],
),
);
}
@@ -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();