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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user