diff --git a/lib/core/widgets/lazurite_app_bar.dart b/lib/core/widgets/lazurite_app_bar.dart index 60c736f..bfd792d 100644 --- a/lib/core/widgets/lazurite_app_bar.dart +++ b/lib/core/widgets/lazurite_app_bar.dart @@ -1,10 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:go_router/go_router.dart'; import 'package:lazurite/core/logging/app_logger.dart'; import 'package:lazurite/core/router/app_shell.dart'; import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; -import 'package:lazurite/features/messages/bloc/convo_list_bloc.dart'; +import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; +import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; /// Custom top app bar for the Lazurite shell screens. /// @@ -45,6 +45,7 @@ class LazuriteAppBar extends StatelessWidget implements PreferredSizeWidget { titleSpacing: 0, actions: [ ...?actions, + const _AppBarOfflineIndicator(), if (showAvatar) ...[const _AppBarAvatar(), const SizedBox(width: 8)], ], bottom: bottom, @@ -53,45 +54,49 @@ class LazuriteAppBar extends StatelessWidget implements PreferredSizeWidget { } } -class AppBarMessagesButton extends StatelessWidget { - const AppBarMessagesButton({super.key}); +class _AppBarOfflineIndicator extends StatelessWidget { + const _AppBarOfflineIndicator(); @override Widget build(BuildContext context) { - final theme = Theme.of(context); - - final baseButton = InkWell( - borderRadius: BorderRadius.circular(4), - onTap: () => GoRouter.maybeOf(context)?.go('/alerts/messages'), - child: Container( - width: 32, - height: 32, - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHigh, - border: Border.all(color: theme.colorScheme.outlineVariant), - ), - child: const Icon(Icons.chat_bubble_outline, size: 18), - ), - ); - + ConnectivityCubit? connectivityCubit; try { - final bloc = context.read(); - return BlocBuilder( - bloc: bloc, - builder: (context, state) { - final unreadCount = state.convos.fold(0, (sum, convo) => sum + convo.unreadCount); - return Badge( - isLabelVisible: unreadCount > 0, - label: Text(unreadCount > 99 ? '99+' : unreadCount.toString(), style: const TextStyle(fontSize: 10)), - child: baseButton, - ); - }, - ); + connectivityCubit = context.read(); } catch (_) { - log.d('showing messages button without unread badge'); + log.d('showing app bar without connectivity indicator'); } + if (connectivityCubit == null) { + return const SizedBox.shrink(); + } + + return BlocBuilder( + bloc: connectivityCubit, + builder: (context, state) { + if (!state.isOffline) { + return const SizedBox.shrink(); + } - return baseButton; + final theme = Theme.of(context); + SettingsCubit? settingsCubit; + try { + settingsCubit = context.read(); + } catch (_) {} + final canDisableSimulatedOffline = state.isSimulatedOffline && settingsCubit != null; + final tooltip = canDisableSimulatedOffline ? 'Disable simulated offline mode' : 'You\'re offline'; + + return Padding( + padding: const EdgeInsets.only(right: 4), + child: Tooltip( + message: tooltip, + child: IconButton( + tooltip: tooltip, + onPressed: canDisableSimulatedOffline ? () => settingsCubit?.setSimulateOffline(false) : null, + icon: Icon(Icons.cloud_off_outlined, color: theme.colorScheme.error), + ), + ), + ); + }, + ); } } diff --git a/lib/features/connectivity/presentation/connectivity_banner_host.dart b/lib/features/connectivity/presentation/connectivity_banner_host.dart index 42befc4..f41085c 100644 --- a/lib/features/connectivity/presentation/connectivity_banner_host.dart +++ b/lib/features/connectivity/presentation/connectivity_banner_host.dart @@ -1,6 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; class ConnectivityBannerHost extends StatelessWidget { const ConnectivityBannerHost({super.key, required this.child}); @@ -8,58 +6,5 @@ class ConnectivityBannerHost extends StatelessWidget { final Widget child; @override - Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, state) { - return Stack( - children: [ - Positioned.fill(child: child), - if (state.isOffline) - Positioned( - top: 0, - left: 0, - right: 0, - child: SafeArea( - bottom: false, - child: IgnorePointer( - ignoring: true, - child: Padding( - padding: const EdgeInsets.all(12), - child: Material( - color: Colors.transparent, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.errorContainer, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: Theme.of(context).colorScheme.error), - ), - child: Row( - children: [ - Icon(Icons.cloud_off, color: Theme.of(context).colorScheme.onErrorContainer, size: 18), - const SizedBox(width: 10), - Expanded( - child: Text( - state.isSimulatedOffline - ? 'You\'re offline (simulated in developer settings).' - : 'You\'re offline.', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onErrorContainer, - fontWeight: FontWeight.w600, - ), - ), - ), - ], - ), - ), - ), - ), - ), - ), - ), - ], - ); - }, - ); - } + Widget build(BuildContext context) => child; } diff --git a/lib/features/feed/presentation/home_feed_screen.dart b/lib/features/feed/presentation/home_feed_screen.dart index f56fe9a..1493568 100644 --- a/lib/features/feed/presentation/home_feed_screen.dart +++ b/lib/features/feed/presentation/home_feed_screen.dart @@ -108,12 +108,7 @@ class _HomeFeedScreenState extends State { return Scaffold( appBar: LazuriteAppBar( sectionLabel: 'Home', - showAvatar: false, - actions: [ - IconButton(icon: const Icon(Icons.rss_feed), onPressed: () => context.push('/feeds')), - const AppBarMessagesButton(), - const SizedBox(width: 8), - ], + actions: [IconButton(icon: const Icon(Icons.rss_feed), onPressed: () => context.push('/feeds'))], bottom: _FeedTabBar( feeds: pinnedFeeds, prefsState: prefsState, @@ -274,7 +269,7 @@ class _FeedListViewState extends State<_FeedListView> with AutomaticKeepAliveCli } Future _loadFeed() async { - await _loadFeedInternal(showLoading: _posts.isEmpty); + await _loadFeedInternal(showLoading: _posts.isEmpty, showOfflineFeedback: true); } Future _primeFeed() async { @@ -290,11 +285,24 @@ class _FeedListViewState extends State<_FeedListView> with AutomaticKeepAliveCli }); } - await _loadFeedInternal(showLoading: cachedResult == null); + await _loadFeedInternal(showLoading: cachedResult == null, showOfflineFeedback: false); } - Future _loadFeedInternal({required bool showLoading}) async { + Future _loadFeedInternal({required bool showLoading, required bool showOfflineFeedback}) async { if (_isLoading) return; + if (context.read().state.isOffline) { + if (_posts.isEmpty) { + _setStateIfMounted(() { + _hasError = true; + _errorMessage = offlineActionMessage('refresh your feed'); + _isLoading = false; + _showInitialLoading = false; + }); + } else if (showOfflineFeedback) { + showOfflineSnackBar(context, action: 'refresh your feed'); + } + return; + } _setStateIfMounted(() { _isLoading = true; @@ -339,6 +347,9 @@ class _FeedListViewState extends State<_FeedListView> with AutomaticKeepAliveCli Future _loadMore() async { if (_isLoadingMore || _cursor == null) return; + if (context.read().state.isOffline) { + return; + } _setStateIfMounted(() => _isLoadingMore = true); diff --git a/test/core/widgets/lazurite_app_bar_test.dart b/test/core/widgets/lazurite_app_bar_test.dart index 1ffe98f..68aff7f 100644 --- a/test/core/widgets/lazurite_app_bar_test.dart +++ b/test/core/widgets/lazurite_app_bar_test.dart @@ -1,22 +1,27 @@ import 'package:bloc_test/bloc_test.dart'; -import 'package:bluesky/chat_bsky_convo_defs.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:lazurite/core/router/app_shell.dart'; +import 'package:lazurite/core/theme/app_theme.dart'; import 'package:lazurite/core/widgets/lazurite_app_bar.dart'; import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; +import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; import 'package:lazurite/features/auth/data/models/auth_models.dart'; -import 'package:lazurite/features/messages/bloc/convo_list_bloc.dart'; +import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; +import 'package:lazurite/features/settings/bloc/settings_state.dart'; import 'package:mocktail/mocktail.dart'; class MockAuthBloc extends MockBloc implements AuthBloc {} -class MockConvoListBloc extends MockBloc implements ConvoListBloc {} +class MockConnectivityCubit extends MockCubit implements ConnectivityCubit {} + +class MockSettingsCubit extends MockCubit implements SettingsCubit {} void main() { late MockAuthBloc authBloc; - late MockConvoListBloc convoListBloc; + late MockConnectivityCubit connectivityCubit; + late MockSettingsCubit settingsCubit; const tokens = AuthTokens( accessToken: 'access', @@ -28,15 +33,33 @@ void main() { setUp(() { authBloc = MockAuthBloc(); - convoListBloc = MockConvoListBloc(); + connectivityCubit = MockConnectivityCubit(); + settingsCubit = MockSettingsCubit(); when(() => authBloc.state).thenReturn(const AuthState.authenticated(tokens)); whenListen(authBloc, const Stream.empty(), initialState: const AuthState.authenticated(tokens)); - when(() => convoListBloc.state).thenReturn(const ConvoListState.loaded(convos: [], cursor: null, hasMore: false)); + when(() => connectivityCubit.state).thenReturn(const ConnectivityState.online()); + whenListen( + connectivityCubit, + const Stream.empty(), + initialState: const ConnectivityState.online(), + ); + when(() => settingsCubit.state).thenReturn( + const SettingsState( + themePalette: AppThemePalette.oxocarbon, + themeVariant: AppThemeVariant.dark, + useSystemTheme: false, + ), + ); whenListen( - convoListBloc, - const Stream.empty(), - initialState: const ConvoListState.loaded(convos: [], cursor: null, hasMore: false), + settingsCubit, + const Stream.empty(), + initialState: const SettingsState( + themePalette: AppThemePalette.oxocarbon, + themeVariant: AppThemeVariant.dark, + useSystemTheme: false, + ), ); + when(() => settingsCubit.setSimulateOffline(any())).thenAnswer((_) async {}); }); Widget buildSubject({ @@ -48,7 +71,8 @@ void main() { return MultiBlocProvider( providers: [ BlocProvider.value(value: authBloc), - BlocProvider.value(value: convoListBloc), + BlocProvider.value(value: connectivityCubit), + BlocProvider.value(value: settingsCubit), ], child: MaterialApp( home: Scaffold( @@ -112,32 +136,32 @@ void main() { buildSubject( sectionLabel: 'Home', showAvatar: false, - actions: [IconButton(onPressed: () {}, icon: const Icon(Icons.chat_bubble_outline))], + actions: [IconButton(onPressed: () {}, icon: const Icon(Icons.rss_feed))], ), ); await tester.pumpAndSettle(); - expect(find.byIcon(Icons.chat_bubble_outline), findsOneWidget); + expect(find.byIcon(Icons.rss_feed), findsOneWidget); expect(find.text('RT'), findsNothing); }); - testWidgets('AppBarMessagesButton shows unread badge from shared convo list state', (tester) async { - const unreadConvo = ConvoView(id: 'c1', rev: 'rev-1', members: [], muted: false, unreadCount: 3); - when( - () => convoListBloc.state, - ).thenReturn(const ConvoListState.loaded(convos: [unreadConvo], cursor: null, hasMore: false)); + testWidgets('shows simulated offline indicator and lets the user disable it', (tester) async { + when(() => connectivityCubit.state).thenReturn(const ConnectivityState.online(isSimulatedOffline: true)); whenListen( - convoListBloc, - const Stream.empty(), - initialState: const ConvoListState.loaded(convos: [unreadConvo], cursor: null, hasMore: false), + connectivityCubit, + const Stream.empty(), + initialState: const ConnectivityState.online(isSimulatedOffline: true), ); - await tester.pumpWidget( - buildSubject(sectionLabel: 'Home', showAvatar: false, actions: [const AppBarMessagesButton()]), - ); + await tester.pumpWidget(buildSubject(sectionLabel: 'Home')); + await tester.pumpAndSettle(); + + expect(find.byTooltip('Disable simulated offline mode'), findsAtLeastNWidgets(1)); + + await tester.tap(find.byTooltip('Disable simulated offline mode').first); await tester.pump(); - expect(find.text('3'), findsOneWidget); + verify(() => settingsCubit.setSimulateOffline(false)).called(1); }); testWidgets('preferred size height is 64 without bottom widget', (tester) async { diff --git a/test/features/feed/presentation/home_feed_screen_test.dart b/test/features/feed/presentation/home_feed_screen_test.dart index a07defc..2ace6f5 100644 --- a/test/features/feed/presentation/home_feed_screen_test.dart +++ b/test/features/feed/presentation/home_feed_screen_test.dart @@ -8,6 +8,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:lazurite/core/theme/app_theme.dart'; import 'package:lazurite/core/theme/feed_architecture.dart'; import 'package:lazurite/core/theme/ui_density.dart'; +import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; +import 'package:lazurite/features/auth/data/models/auth_models.dart'; import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; import 'package:lazurite/features/feed/cubit/feed_preferences_cubit.dart'; import 'package:lazurite/features/feed/data/feed_repository.dart'; @@ -25,6 +27,8 @@ class MockFeedRepository extends Mock implements FeedRepository {} class MockConnectivityCubit extends MockCubit implements ConnectivityCubit {} +class MockAuthBloc extends MockBloc implements AuthBloc {} + SettingsState _settingsState(FeedArchitecture architecture) => SettingsState( themePalette: AppThemePalette.oxocarbon, themeVariant: AppThemeVariant.dark, @@ -72,13 +76,21 @@ void main() { Widget buildHomeSubject({ required FeedPreferencesCubit feedPreferencesCubit, required FeedRepository feedRepository, + ConnectivityState connectivityState = const ConnectivityState.online(), }) { final connectivityCubit = MockConnectivityCubit(); - when(() => connectivityCubit.state).thenReturn(const ConnectivityState.online()); + final authBloc = MockAuthBloc(); + when(() => connectivityCubit.state).thenReturn(connectivityState); + whenListen(connectivityCubit, const Stream.empty(), initialState: connectivityState); + when(() => authBloc.state).thenReturn( + const AuthState.authenticated(AuthTokens(accessToken: 'access', did: 'did:plc:test', handle: 'test.bsky.social')), + ); whenListen( - connectivityCubit, - const Stream.empty(), - initialState: const ConnectivityState.online(), + authBloc, + const Stream.empty(), + initialState: const AuthState.authenticated( + AuthTokens(accessToken: 'access', did: 'did:plc:test', handle: 'test.bsky.social'), + ), ); return MaterialApp( @@ -86,6 +98,7 @@ void main() { value: feedRepository, child: MultiBlocProvider( providers: [ + BlocProvider.value(value: authBloc), BlocProvider.value(value: feedPreferencesCubit), BlocProvider.value(value: connectivityCubit), ], @@ -309,6 +322,30 @@ void main() { }); group('HomeFeedScreen', () { + testWidgets('shows feeds action without the messages shortcut in the app bar', (tester) async { + final feedPreferencesCubit = MockFeedPreferencesCubit(); + final feedRepository = MockFeedRepository(); + final completer = Completer(); + + when(() => feedPreferencesCubit.state).thenReturn(_homeFeedState); + whenListen(feedPreferencesCubit, const Stream.empty(), initialState: _homeFeedState); + when(() => feedRepository.getCachedFeedPage(any())).thenAnswer((_) async => null); + when( + () => feedRepository.getTimeline( + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenAnswer((_) => completer.future); + + await tester.pumpWidget( + buildHomeSubject(feedPreferencesCubit: feedPreferencesCubit, feedRepository: feedRepository), + ); + await tester.pump(); + + expect(find.byIcon(Icons.rss_feed), findsOneWidget); + expect(find.byIcon(Icons.chat_bubble_outline), findsNothing); + }); + testWidgets('uses a non-default compose hero tag', (tester) async { final feedPreferencesCubit = MockFeedPreferencesCubit(); final feedRepository = MockFeedRepository(); @@ -365,5 +402,32 @@ void main() { expect(errors.where((error) => error.exceptionAsString().contains('setState() called after dispose()')), isEmpty); }); + + testWidgets('does not fetch the feed when offline and shows an offline message', (tester) async { + final feedPreferencesCubit = MockFeedPreferencesCubit(); + final feedRepository = MockFeedRepository(); + + when(() => feedPreferencesCubit.state).thenReturn(_homeFeedState); + whenListen(feedPreferencesCubit, const Stream.empty(), initialState: _homeFeedState); + when(() => feedRepository.getCachedFeedPage(any())).thenAnswer((_) async => null); + + await tester.pumpWidget( + buildHomeSubject( + feedPreferencesCubit: feedPreferencesCubit, + feedRepository: feedRepository, + connectivityState: const ConnectivityState.online(isSimulatedOffline: true), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Failed to load feed'), findsOneWidget); + expect(find.textContaining('You\'re offline'), findsOneWidget); + verifyNever( + () => feedRepository.getTimeline( + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ); + }); }); }