merged develop
This commit is contained in:
+95
-22
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user