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:
2026-08-02 08:21:25 +02:00
parent 4c2e9b47e7
commit 246cb0f527
7 changed files with 36 additions and 195 deletions
+9 -2
View File
@@ -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,
);
});
});
}