simplified loadable state logic by removing connectivity debouncing and inlining indicator helpers; updated error bar visibility and chat list polling behavior
This commit is contained in:
+9
-2
@@ -62,7 +62,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
|||||||
_syncChatListPolling();
|
_syncChatListPolling();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _syncChatListPolling() {
|
void _syncChatListPolling({bool refresh = true}) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final modules = AppModule.getBottomBarModules(context);
|
final modules = AppModule.getBottomBarModules(context);
|
||||||
final talkSlot = modules.indexWhere((m) => m.module == Modules.talk);
|
final talkSlot = modules.indexWhere((m) => m.module == Modules.talk);
|
||||||
@@ -72,7 +72,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
|||||||
bloc.setAutoRefreshInterval(
|
bloc.setAutoRefreshInterval(
|
||||||
talkIsActive ? _chatListActiveInterval : _chatListIdleInterval,
|
talkIsActive ? _chatListActiveInterval : _chatListIdleInterval,
|
||||||
);
|
);
|
||||||
if (talkIsActive) bloc.refresh();
|
if (talkIsActive && refresh) bloc.refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wall-clock throttle rather than Debouncer.throttle: a Timer does not tick
|
// Wall-clock throttle rather than Debouncer.throttle: a Timer does not tick
|
||||||
@@ -103,7 +103,14 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
|||||||
log('Refreshing due to LifecycleChange');
|
log('Refreshing due to LifecycleChange');
|
||||||
NotificationTasks.updateProviders(context);
|
NotificationTasks.updateProviders(context);
|
||||||
});
|
});
|
||||||
|
// updateProviders already refreshes the chat list; only re-arm the poll.
|
||||||
|
_syncChatListPolling(refresh: false);
|
||||||
_handlePendingWidgetNavigation();
|
_handlePendingWidgetNavigation();
|
||||||
|
} else if (mounted) {
|
||||||
|
// Stop polling while backgrounded: a silent refresh failing in the
|
||||||
|
// background would otherwise leave an error that flashes on the next
|
||||||
|
// resume before the foreground refetch replaces it.
|
||||||
|
context.read<ChatListBloc>().setAutoRefreshInterval(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
|
||||||
import '../../../../../extensions/date_time.dart';
|
import '../../../../../extensions/date_time.dart';
|
||||||
import '../loadable_state_indicators.dart';
|
|
||||||
import 'loadable_state_event.dart';
|
import 'loadable_state_event.dart';
|
||||||
import 'loadable_state_state.dart';
|
import 'loadable_state_state.dart';
|
||||||
|
|
||||||
@@ -14,75 +13,48 @@ class LoadableStateBloc extends Bloc<LoadableStateEvent, LoadableStateState>
|
|||||||
late StreamSubscription<List<ConnectivityResult>> _updateStream;
|
late StreamSubscription<List<ConnectivityResult>> _updateStream;
|
||||||
void Function()? reFetch;
|
void Function()? reFetch;
|
||||||
|
|
||||||
static const Duration _refetchThrottle = Duration(seconds: 10);
|
/// Last time [reFetch] was triggered by an [AppLifecycleState.resumed]
|
||||||
|
/// event. Used to coalesce rapid foreground/background flips so we don't
|
||||||
/// Last time [reFetch] was triggered automatically (resume or reconnect).
|
/// spam the network when the user briefly checks notifications.
|
||||||
/// Used to coalesce rapid foreground/background flips so we don't spam the
|
DateTime _lastResumeRefetch = DateTime.fromMillisecondsSinceEpoch(0);
|
||||||
/// network when the user briefly checks notifications.
|
|
||||||
DateTime _lastRefetch = DateTime.fromMillisecondsSinceEpoch(0);
|
|
||||||
|
|
||||||
/// Delays committing a transition *into* the offline state. On resume
|
|
||||||
/// connectivity_plus frequently emits a transient `[none]` before the real
|
|
||||||
/// result lands; debouncing it prevents the offline bar from flashing.
|
|
||||||
Timer? _offlineDebounce;
|
|
||||||
|
|
||||||
LoadableStateBloc() : super(const LoadableStateState(connections: null)) {
|
LoadableStateBloc() : super(const LoadableStateState(connections: null)) {
|
||||||
on<ConnectivityChanged>((event, emit) {
|
on<ConnectivityChanged>((event, emit) {
|
||||||
emit(event.state);
|
emit(event.state);
|
||||||
if (connectivityStatusKnown() && isConnected()) _triggerRefetch();
|
if (connectivityStatusKnown() && isConnected()) {
|
||||||
|
if (reFetch == null) return;
|
||||||
|
reFetch!();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Connectivity().checkConnectivity().then(_emitConnectivity);
|
void emitConnectivity(List<ConnectivityResult> result) {
|
||||||
_updateStream = Connectivity().onConnectivityChanged.listen(
|
|
||||||
_emitConnectivity,
|
|
||||||
);
|
|
||||||
WidgetsBinding.instance.addObserver(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _emitConnectivity(List<ConnectivityResult> result) {
|
|
||||||
_offlineDebounce?.cancel();
|
|
||||||
// Commit a connected reading immediately (snappy recovery); only debounce
|
|
||||||
// the drop to offline so a transient tick doesn't surface as offline.
|
|
||||||
if (!result.contains(ConnectivityResult.none)) {
|
|
||||||
// The initial checkConnectivity() future is not cancellable and may
|
// The initial checkConnectivity() future is not cancellable and may
|
||||||
// resolve after the bloc was disposed, so guard against a closed sink.
|
// resolve after the bloc was disposed, so guard against a closed sink.
|
||||||
if (isClosed) return;
|
if (isClosed) return;
|
||||||
add(ConnectivityChanged(LoadableStateState(connections: result)));
|
add(ConnectivityChanged(LoadableStateState(connections: result)));
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
_offlineDebounce = Timer(const Duration(milliseconds: 1200), () {
|
|
||||||
if (isClosed) return;
|
|
||||||
add(ConnectivityChanged(LoadableStateState(connections: result)));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _triggerRefetch() {
|
Connectivity().checkConnectivity().then(emitConnectivity);
|
||||||
final now = DateTime.now();
|
_updateStream = Connectivity().onConnectivityChanged.listen(
|
||||||
if (!shouldRefetch(
|
emitConnectivity,
|
||||||
_lastRefetch,
|
);
|
||||||
now,
|
WidgetsBinding.instance.addObserver(this);
|
||||||
_refetchThrottle,
|
|
||||||
hasReFetch: reFetch != null,
|
|
||||||
)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_lastRefetch = now;
|
|
||||||
reFetch!();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
if (state != AppLifecycleState.resumed) return;
|
if (state != AppLifecycleState.resumed) return;
|
||||||
// Trigger the refetch synchronously so [RefetchStarted] clears any stale
|
final now = DateTime.now();
|
||||||
// error and enters the loading state before the first resume frame paints,
|
if (now.difference(_lastResumeRefetch) < const Duration(seconds: 10)) {
|
||||||
// instead of only after the async connectivity round-trip below.
|
return;
|
||||||
_triggerRefetch();
|
}
|
||||||
// Still re-check connectivity so the (debounced) offline bar reflects the
|
_lastResumeRefetch = now;
|
||||||
// real network state after resume.
|
// Re-check connectivity so the resulting [ConnectivityChanged] handler
|
||||||
|
// clears a stale error bar and triggers [reFetch] once reachable again.
|
||||||
unawaited(
|
unawaited(
|
||||||
Connectivity().checkConnectivity().then((result) {
|
Connectivity().checkConnectivity().then((result) {
|
||||||
if (isClosed) return;
|
if (isClosed) return;
|
||||||
_emitConnectivity(result);
|
add(ConnectivityChanged(LoadableStateState(connections: result)));
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -120,7 +92,6 @@ class LoadableStateBloc extends Bloc<LoadableStateEvent, LoadableStateState>
|
|||||||
@override
|
@override
|
||||||
Future<void> close() {
|
Future<void> close() {
|
||||||
WidgetsBinding.instance.removeObserver(this);
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
_offlineDebounce?.cancel();
|
|
||||||
_updateStream.cancel();
|
_updateStream.cancel();
|
||||||
return super.close();
|
return super.close();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
// Pure decision helpers for the shared loadable-state indicators. Kept free of
|
|
||||||
// Flutter/connectivity imports so they can be unit-tested in isolation.
|
|
||||||
|
|
||||||
/// Whether the offline bar should be shown over cached content. Suppressed while
|
|
||||||
/// a fetch is in flight so a transient connectivity dip does not flash the bar
|
|
||||||
/// during the resume/refresh window.
|
|
||||||
bool shouldShowOfflineBar({
|
|
||||||
required bool hasContent,
|
|
||||||
required bool loading,
|
|
||||||
required bool connectivityKnown,
|
|
||||||
required bool connected,
|
|
||||||
}) => hasContent && !loading && connectivityKnown && !connected;
|
|
||||||
|
|
||||||
/// Whether an automatic refetch may run: only when a refetch callback exists and
|
|
||||||
/// the throttle [window] since the [last] trigger has elapsed.
|
|
||||||
bool shouldRefetch(
|
|
||||||
DateTime last,
|
|
||||||
DateTime now,
|
|
||||||
Duration window, {
|
|
||||||
required bool hasReFetch,
|
|
||||||
}) => hasReFetch && now.difference(last) >= window;
|
|
||||||
@@ -62,8 +62,8 @@ class LoadableStateConsumer<
|
|||||||
|
|
||||||
final showPrimaryLoading = isLoading && !hasContent;
|
final showPrimaryLoading = isLoading && !hasContent;
|
||||||
final showBackgroundLoading = isLoading && hasContent;
|
final showBackgroundLoading = isLoading && hasContent;
|
||||||
final showError = hasError && !hasContent && !isLoading;
|
final showError = hasError && !hasContent;
|
||||||
final showErrorBar = hasError && hasContent && !isLoading;
|
final showErrorBar = hasError && hasContent;
|
||||||
|
|
||||||
// Keep the wrapper hierarchy stable across refresh cycles: reFetch flips to
|
// Keep the wrapper hierarchy stable across refresh cycles: reFetch flips to
|
||||||
// null mid-refetch, and toggling the RefreshIndicator on that signal would
|
// null mid-refetch, and toggling the RefreshIndicator on that signal would
|
||||||
@@ -102,7 +102,6 @@ class LoadableStateConsumer<
|
|||||||
LoadableStateErrorBar(
|
LoadableStateErrorBar(
|
||||||
visible: showErrorBar,
|
visible: showErrorBar,
|
||||||
hasContent: hasContent,
|
hasContent: hasContent,
|
||||||
loading: isLoading,
|
|
||||||
message: loadableState.error?.message,
|
message: loadableState.error?.message,
|
||||||
technicalDetails: loadableState.error?.technicalDetails,
|
technicalDetails: loadableState.error?.technicalDetails,
|
||||||
lastUpdated: loadableState.lastFetch,
|
lastUpdated: loadableState.lastFetch,
|
||||||
|
|||||||
@@ -5,19 +5,16 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
|||||||
|
|
||||||
import '../../../../../widget/info_dialog.dart';
|
import '../../../../../widget/info_dialog.dart';
|
||||||
import '../bloc/loadable_state_bloc.dart';
|
import '../bloc/loadable_state_bloc.dart';
|
||||||
import '../loadable_state_indicators.dart';
|
|
||||||
|
|
||||||
class LoadableStateErrorBar extends StatelessWidget {
|
class LoadableStateErrorBar extends StatelessWidget {
|
||||||
final bool visible;
|
final bool visible;
|
||||||
final bool hasContent;
|
final bool hasContent;
|
||||||
final bool loading;
|
|
||||||
final String? message;
|
final String? message;
|
||||||
final String? technicalDetails;
|
final String? technicalDetails;
|
||||||
final int? lastUpdated;
|
final int? lastUpdated;
|
||||||
const LoadableStateErrorBar({
|
const LoadableStateErrorBar({
|
||||||
required this.visible,
|
required this.visible,
|
||||||
this.hasContent = false,
|
this.hasContent = false,
|
||||||
this.loading = false,
|
|
||||||
this.message,
|
this.message,
|
||||||
this.technicalDetails,
|
this.technicalDetails,
|
||||||
this.lastUpdated,
|
this.lastUpdated,
|
||||||
@@ -29,12 +26,8 @@ class LoadableStateErrorBar extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final bloc = context.watch<LoadableStateBloc>();
|
final bloc = context.watch<LoadableStateBloc>();
|
||||||
final isOfflineWithCache = shouldShowOfflineBar(
|
final isOfflineWithCache =
|
||||||
hasContent: hasContent,
|
hasContent && bloc.connectivityStatusKnown() && !bloc.isConnected();
|
||||||
loading: loading,
|
|
||||||
connectivityKnown: bloc.connectivityStatusKnown(),
|
|
||||||
connected: bloc.isConnected(),
|
|
||||||
);
|
|
||||||
final shouldShow = visible || isOfflineWithCache;
|
final shouldShow = visible || isOfflineWithCache;
|
||||||
|
|
||||||
return AnimatedSize(
|
return AnimatedSize(
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ class FilesSearchResults extends StatelessWidget {
|
|||||||
final showPrimaryLoading = isLoading && !hasContent;
|
final showPrimaryLoading = isLoading && !hasContent;
|
||||||
final showBackgroundLoading = isLoading && hasContent;
|
final showBackgroundLoading = isLoading && hasContent;
|
||||||
final showErrorScreen = hasError && !hasContent && !isLoading;
|
final showErrorScreen = hasError && !hasContent && !isLoading;
|
||||||
final showErrorBar = hasError && hasContent && !isLoading;
|
final showErrorBar = hasError && hasContent;
|
||||||
final showEmpty = !hasContent && !hasError && !isLoading;
|
final showEmpty = !hasContent && !hasError && !isLoading;
|
||||||
|
|
||||||
final errorMessage = hasError ? errorToUserMessage(controller.serverError) : null;
|
final errorMessage = hasError ? errorToUserMessage(controller.serverError) : null;
|
||||||
@@ -65,7 +65,6 @@ class FilesSearchResults extends StatelessWidget {
|
|||||||
LoadableStateErrorBar(
|
LoadableStateErrorBar(
|
||||||
visible: showErrorBar,
|
visible: showErrorBar,
|
||||||
hasContent: hasContent,
|
hasContent: hasContent,
|
||||||
loading: isLoading,
|
|
||||||
message: errorMessage,
|
message: errorMessage,
|
||||||
),
|
),
|
||||||
// Background loading sits *outside* the result Stack so the linear
|
// Background loading sits *outside* the result Stack so the linear
|
||||||
|
|||||||
@@ -1,107 +0,0 @@
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
import 'package:marianum_mobile/state/app/infrastructure/loadable_state/loadable_state_indicators.dart';
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
group('shouldShowOfflineBar', () {
|
|
||||||
test('shows when offline with cache and not loading', () {
|
|
||||||
expect(
|
|
||||||
shouldShowOfflineBar(
|
|
||||||
hasContent: true,
|
|
||||||
loading: false,
|
|
||||||
connectivityKnown: true,
|
|
||||||
connected: false,
|
|
||||||
),
|
|
||||||
isTrue,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('is suppressed while loading even if offline with cache', () {
|
|
||||||
expect(
|
|
||||||
shouldShowOfflineBar(
|
|
||||||
hasContent: true,
|
|
||||||
loading: true,
|
|
||||||
connectivityKnown: true,
|
|
||||||
connected: false,
|
|
||||||
),
|
|
||||||
isFalse,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('is hidden without cached content', () {
|
|
||||||
expect(
|
|
||||||
shouldShowOfflineBar(
|
|
||||||
hasContent: false,
|
|
||||||
loading: false,
|
|
||||||
connectivityKnown: true,
|
|
||||||
connected: false,
|
|
||||||
),
|
|
||||||
isFalse,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('is hidden while connectivity is unknown', () {
|
|
||||||
expect(
|
|
||||||
shouldShowOfflineBar(
|
|
||||||
hasContent: true,
|
|
||||||
loading: false,
|
|
||||||
connectivityKnown: false,
|
|
||||||
connected: false,
|
|
||||||
),
|
|
||||||
isFalse,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('is hidden when connected', () {
|
|
||||||
expect(
|
|
||||||
shouldShowOfflineBar(
|
|
||||||
hasContent: true,
|
|
||||||
loading: false,
|
|
||||||
connectivityKnown: true,
|
|
||||||
connected: true,
|
|
||||||
),
|
|
||||||
isFalse,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
group('shouldRefetch', () {
|
|
||||||
final window = const Duration(seconds: 10);
|
|
||||||
final base = DateTime(2026, 1, 1, 12, 0, 0);
|
|
||||||
|
|
||||||
test('is false within the throttle window', () {
|
|
||||||
expect(
|
|
||||||
shouldRefetch(
|
|
||||||
base,
|
|
||||||
base.add(const Duration(seconds: 9)),
|
|
||||||
window,
|
|
||||||
hasReFetch: true,
|
|
||||||
),
|
|
||||||
isFalse,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('is true once the window has elapsed', () {
|
|
||||||
expect(
|
|
||||||
shouldRefetch(
|
|
||||||
base,
|
|
||||||
base.add(const Duration(seconds: 10)),
|
|
||||||
window,
|
|
||||||
hasReFetch: true,
|
|
||||||
),
|
|
||||||
isTrue,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('is false without a refetch callback', () {
|
|
||||||
expect(
|
|
||||||
shouldRefetch(
|
|
||||||
base,
|
|
||||||
base.add(const Duration(minutes: 5)),
|
|
||||||
window,
|
|
||||||
hasReFetch: false,
|
|
||||||
),
|
|
||||||
isFalse,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user