diff --git a/lib/app.dart b/lib/app.dart index deb94ac..7d27af4 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -62,7 +62,7 @@ class _AppState extends State with WidgetsBindingObserver { _syncChatListPolling(); } - void _syncChatListPolling() { + void _syncChatListPolling({bool refresh = true}) { if (!mounted) return; final modules = AppModule.getBottomBarModules(context); final talkSlot = modules.indexWhere((m) => m.module == Modules.talk); @@ -72,7 +72,7 @@ class _AppState extends State with WidgetsBindingObserver { bloc.setAutoRefreshInterval( talkIsActive ? _chatListActiveInterval : _chatListIdleInterval, ); - if (talkIsActive) bloc.refresh(); + if (talkIsActive && refresh) bloc.refresh(); } // Wall-clock throttle rather than Debouncer.throttle: a Timer does not tick @@ -103,7 +103,14 @@ class _AppState extends State with WidgetsBindingObserver { log('Refreshing due to LifecycleChange'); NotificationTasks.updateProviders(context); }); + // updateProviders already refreshes the chat list; only re-arm the poll. + _syncChatListPolling(refresh: false); _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().setAutoRefreshInterval(null); } } diff --git a/lib/state/app/infrastructure/loadable_state/bloc/loadable_state_bloc.dart b/lib/state/app/infrastructure/loadable_state/bloc/loadable_state_bloc.dart index 195f920..9c07d3b 100644 --- a/lib/state/app/infrastructure/loadable_state/bloc/loadable_state_bloc.dart +++ b/lib/state/app/infrastructure/loadable_state/bloc/loadable_state_bloc.dart @@ -5,7 +5,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../../../extensions/date_time.dart'; -import '../loadable_state_indicators.dart'; import 'loadable_state_event.dart'; import 'loadable_state_state.dart'; @@ -14,75 +13,48 @@ class LoadableStateBloc extends Bloc late StreamSubscription> _updateStream; void Function()? reFetch; - static const Duration _refetchThrottle = Duration(seconds: 10); - - /// Last time [reFetch] was triggered automatically (resume or reconnect). - /// Used to coalesce rapid foreground/background flips so we don't spam the - /// 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; + /// Last time [reFetch] was triggered by an [AppLifecycleState.resumed] + /// event. Used to coalesce rapid foreground/background flips so we don't + /// spam the network when the user briefly checks notifications. + DateTime _lastResumeRefetch = DateTime.fromMillisecondsSinceEpoch(0); LoadableStateBloc() : super(const LoadableStateState(connections: null)) { on((event, emit) { emit(event.state); - if (connectivityStatusKnown() && isConnected()) _triggerRefetch(); + if (connectivityStatusKnown() && isConnected()) { + if (reFetch == null) return; + reFetch!(); + } }); - Connectivity().checkConnectivity().then(_emitConnectivity); - _updateStream = Connectivity().onConnectivityChanged.listen( - _emitConnectivity, - ); - WidgetsBinding.instance.addObserver(this); - } - - void _emitConnectivity(List 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)) { + void emitConnectivity(List result) { // The initial checkConnectivity() future is not cancellable and may // resolve after the bloc was disposed, so guard against a closed sink. if (isClosed) return; add(ConnectivityChanged(LoadableStateState(connections: result))); - return; } - _offlineDebounce = Timer(const Duration(milliseconds: 1200), () { - if (isClosed) return; - add(ConnectivityChanged(LoadableStateState(connections: result))); - }); - } - void _triggerRefetch() { - final now = DateTime.now(); - if (!shouldRefetch( - _lastRefetch, - now, - _refetchThrottle, - hasReFetch: reFetch != null, - )) { - return; - } - _lastRefetch = now; - reFetch!(); + Connectivity().checkConnectivity().then(emitConnectivity); + _updateStream = Connectivity().onConnectivityChanged.listen( + emitConnectivity, + ); + WidgetsBinding.instance.addObserver(this); } @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state != AppLifecycleState.resumed) return; - // Trigger the refetch synchronously so [RefetchStarted] clears any stale - // error and enters the loading state before the first resume frame paints, - // instead of only after the async connectivity round-trip below. - _triggerRefetch(); - // Still re-check connectivity so the (debounced) offline bar reflects the - // real network state after resume. + final now = DateTime.now(); + if (now.difference(_lastResumeRefetch) < const Duration(seconds: 10)) { + return; + } + _lastResumeRefetch = now; + // Re-check connectivity so the resulting [ConnectivityChanged] handler + // clears a stale error bar and triggers [reFetch] once reachable again. unawaited( Connectivity().checkConnectivity().then((result) { if (isClosed) return; - _emitConnectivity(result); + add(ConnectivityChanged(LoadableStateState(connections: result))); }), ); } @@ -120,7 +92,6 @@ class LoadableStateBloc extends Bloc @override Future close() { WidgetsBinding.instance.removeObserver(this); - _offlineDebounce?.cancel(); _updateStream.cancel(); return super.close(); } diff --git a/lib/state/app/infrastructure/loadable_state/loadable_state_indicators.dart b/lib/state/app/infrastructure/loadable_state/loadable_state_indicators.dart deleted file mode 100644 index fadf162..0000000 --- a/lib/state/app/infrastructure/loadable_state/loadable_state_indicators.dart +++ /dev/null @@ -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; diff --git a/lib/state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart b/lib/state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart index a90711a..7ddfa98 100644 --- a/lib/state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart +++ b/lib/state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart @@ -62,8 +62,8 @@ class LoadableStateConsumer< final showPrimaryLoading = isLoading && !hasContent; final showBackgroundLoading = isLoading && hasContent; - final showError = hasError && !hasContent && !isLoading; - final showErrorBar = hasError && hasContent && !isLoading; + final showError = hasError && !hasContent; + final showErrorBar = hasError && hasContent; // Keep the wrapper hierarchy stable across refresh cycles: reFetch flips to // null mid-refetch, and toggling the RefreshIndicator on that signal would @@ -102,7 +102,6 @@ class LoadableStateConsumer< LoadableStateErrorBar( visible: showErrorBar, hasContent: hasContent, - loading: isLoading, message: loadableState.error?.message, technicalDetails: loadableState.error?.technicalDetails, lastUpdated: loadableState.lastFetch, diff --git a/lib/state/app/infrastructure/loadable_state/view/loadable_state_error_bar.dart b/lib/state/app/infrastructure/loadable_state/view/loadable_state_error_bar.dart index 670ab16..417af24 100644 --- a/lib/state/app/infrastructure/loadable_state/view/loadable_state_error_bar.dart +++ b/lib/state/app/infrastructure/loadable_state/view/loadable_state_error_bar.dart @@ -5,19 +5,16 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../../../widget/info_dialog.dart'; import '../bloc/loadable_state_bloc.dart'; -import '../loadable_state_indicators.dart'; class LoadableStateErrorBar extends StatelessWidget { final bool visible; final bool hasContent; - final bool loading; final String? message; final String? technicalDetails; final int? lastUpdated; const LoadableStateErrorBar({ required this.visible, this.hasContent = false, - this.loading = false, this.message, this.technicalDetails, this.lastUpdated, @@ -29,12 +26,8 @@ class LoadableStateErrorBar extends StatelessWidget { @override Widget build(BuildContext context) { final bloc = context.watch(); - final isOfflineWithCache = shouldShowOfflineBar( - hasContent: hasContent, - loading: loading, - connectivityKnown: bloc.connectivityStatusKnown(), - connected: bloc.isConnected(), - ); + final isOfflineWithCache = + hasContent && bloc.connectivityStatusKnown() && !bloc.isConnected(); final shouldShow = visible || isOfflineWithCache; return AnimatedSize( diff --git a/lib/view/pages/files/search/files_search_results.dart b/lib/view/pages/files/search/files_search_results.dart index 0c08d7a..ff9b611 100644 --- a/lib/view/pages/files/search/files_search_results.dart +++ b/lib/view/pages/files/search/files_search_results.dart @@ -55,7 +55,7 @@ class FilesSearchResults extends StatelessWidget { final showPrimaryLoading = isLoading && !hasContent; final showBackgroundLoading = isLoading && hasContent; final showErrorScreen = hasError && !hasContent && !isLoading; - final showErrorBar = hasError && hasContent && !isLoading; + final showErrorBar = hasError && hasContent; final showEmpty = !hasContent && !hasError && !isLoading; final errorMessage = hasError ? errorToUserMessage(controller.serverError) : null; @@ -65,7 +65,6 @@ class FilesSearchResults extends StatelessWidget { LoadableStateErrorBar( visible: showErrorBar, hasContent: hasContent, - loading: isLoading, message: errorMessage, ), // Background loading sits *outside* the result Stack so the linear diff --git a/test/state/loadable_state_visibility_test.dart b/test/state/loadable_state_visibility_test.dart deleted file mode 100644 index 15e9542..0000000 --- a/test/state/loadable_state_visibility_test.dart +++ /dev/null @@ -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, - ); - }); - }); -}