mobile bluesky app made with flutter lazurite.stormlightlabs.org/
mobile bluesky flutter
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor: remove messaging icon

* move offline indicator

* fix offline refresh guard

+176 -127
+39 -34
lib/core/widgets/lazurite_app_bar.dart
··· 1 1 import 'package:flutter/material.dart'; 2 2 import 'package:flutter_bloc/flutter_bloc.dart'; 3 - import 'package:go_router/go_router.dart'; 4 3 import 'package:lazurite/core/logging/app_logger.dart'; 5 4 import 'package:lazurite/core/router/app_shell.dart'; 6 5 import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 7 - import 'package:lazurite/features/messages/bloc/convo_list_bloc.dart'; 6 + import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; 7 + import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 8 8 9 9 /// Custom top app bar for the Lazurite shell screens. 10 10 /// ··· 45 45 titleSpacing: 0, 46 46 actions: [ 47 47 ...?actions, 48 + const _AppBarOfflineIndicator(), 48 49 if (showAvatar) ...[const _AppBarAvatar(), const SizedBox(width: 8)], 49 50 ], 50 51 bottom: bottom, ··· 53 54 } 54 55 } 55 56 56 - class AppBarMessagesButton extends StatelessWidget { 57 - const AppBarMessagesButton({super.key}); 57 + class _AppBarOfflineIndicator extends StatelessWidget { 58 + const _AppBarOfflineIndicator(); 58 59 59 60 @override 60 61 Widget build(BuildContext context) { 61 - final theme = Theme.of(context); 62 - 63 - final baseButton = InkWell( 64 - borderRadius: BorderRadius.circular(4), 65 - onTap: () => GoRouter.maybeOf(context)?.go('/alerts/messages'), 66 - child: Container( 67 - width: 32, 68 - height: 32, 69 - decoration: BoxDecoration( 70 - color: theme.colorScheme.surfaceContainerHigh, 71 - border: Border.all(color: theme.colorScheme.outlineVariant), 72 - ), 73 - child: const Icon(Icons.chat_bubble_outline, size: 18), 74 - ), 75 - ); 76 - 62 + ConnectivityCubit? connectivityCubit; 77 63 try { 78 - final bloc = context.read<ConvoListBloc>(); 79 - return BlocBuilder<ConvoListBloc, ConvoListState>( 80 - bloc: bloc, 81 - builder: (context, state) { 82 - final unreadCount = state.convos.fold<int>(0, (sum, convo) => sum + convo.unreadCount); 83 - return Badge( 84 - isLabelVisible: unreadCount > 0, 85 - label: Text(unreadCount > 99 ? '99+' : unreadCount.toString(), style: const TextStyle(fontSize: 10)), 86 - child: baseButton, 87 - ); 88 - }, 89 - ); 64 + connectivityCubit = context.read<ConnectivityCubit>(); 90 65 } catch (_) { 91 - log.d('showing messages button without unread badge'); 66 + log.d('showing app bar without connectivity indicator'); 67 + } 68 + if (connectivityCubit == null) { 69 + return const SizedBox.shrink(); 92 70 } 93 71 94 - return baseButton; 72 + return BlocBuilder<ConnectivityCubit, ConnectivityState>( 73 + bloc: connectivityCubit, 74 + builder: (context, state) { 75 + if (!state.isOffline) { 76 + return const SizedBox.shrink(); 77 + } 78 + 79 + final theme = Theme.of(context); 80 + SettingsCubit? settingsCubit; 81 + try { 82 + settingsCubit = context.read<SettingsCubit>(); 83 + } catch (_) {} 84 + final canDisableSimulatedOffline = state.isSimulatedOffline && settingsCubit != null; 85 + final tooltip = canDisableSimulatedOffline ? 'Disable simulated offline mode' : 'You\'re offline'; 86 + 87 + return Padding( 88 + padding: const EdgeInsets.only(right: 4), 89 + child: Tooltip( 90 + message: tooltip, 91 + child: IconButton( 92 + tooltip: tooltip, 93 + onPressed: canDisableSimulatedOffline ? () => settingsCubit?.setSimulateOffline(false) : null, 94 + icon: Icon(Icons.cloud_off_outlined, color: theme.colorScheme.error), 95 + ), 96 + ), 97 + ); 98 + }, 99 + ); 95 100 } 96 101 } 97 102
+1 -56
lib/features/connectivity/presentation/connectivity_banner_host.dart
··· 1 1 import 'package:flutter/material.dart'; 2 - import 'package:flutter_bloc/flutter_bloc.dart'; 3 - import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; 4 2 5 3 class ConnectivityBannerHost extends StatelessWidget { 6 4 const ConnectivityBannerHost({super.key, required this.child}); ··· 8 6 final Widget child; 9 7 10 8 @override 11 - Widget build(BuildContext context) { 12 - return BlocBuilder<ConnectivityCubit, ConnectivityState>( 13 - builder: (context, state) { 14 - return Stack( 15 - children: [ 16 - Positioned.fill(child: child), 17 - if (state.isOffline) 18 - Positioned( 19 - top: 0, 20 - left: 0, 21 - right: 0, 22 - child: SafeArea( 23 - bottom: false, 24 - child: IgnorePointer( 25 - ignoring: true, 26 - child: Padding( 27 - padding: const EdgeInsets.all(12), 28 - child: Material( 29 - color: Colors.transparent, 30 - child: Container( 31 - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), 32 - decoration: BoxDecoration( 33 - color: Theme.of(context).colorScheme.errorContainer, 34 - borderRadius: BorderRadius.circular(16), 35 - border: Border.all(color: Theme.of(context).colorScheme.error), 36 - ), 37 - child: Row( 38 - children: [ 39 - Icon(Icons.cloud_off, color: Theme.of(context).colorScheme.onErrorContainer, size: 18), 40 - const SizedBox(width: 10), 41 - Expanded( 42 - child: Text( 43 - state.isSimulatedOffline 44 - ? 'You\'re offline (simulated in developer settings).' 45 - : 'You\'re offline.', 46 - style: Theme.of(context).textTheme.bodyMedium?.copyWith( 47 - color: Theme.of(context).colorScheme.onErrorContainer, 48 - fontWeight: FontWeight.w600, 49 - ), 50 - ), 51 - ), 52 - ], 53 - ), 54 - ), 55 - ), 56 - ), 57 - ), 58 - ), 59 - ), 60 - ], 61 - ); 62 - }, 63 - ); 64 - } 9 + Widget build(BuildContext context) => child; 65 10 }
+20 -9
lib/features/feed/presentation/home_feed_screen.dart
··· 108 108 return Scaffold( 109 109 appBar: LazuriteAppBar( 110 110 sectionLabel: 'Home', 111 - showAvatar: false, 112 - actions: [ 113 - IconButton(icon: const Icon(Icons.rss_feed), onPressed: () => context.push('/feeds')), 114 - const AppBarMessagesButton(), 115 - const SizedBox(width: 8), 116 - ], 111 + actions: [IconButton(icon: const Icon(Icons.rss_feed), onPressed: () => context.push('/feeds'))], 117 112 bottom: _FeedTabBar( 118 113 feeds: pinnedFeeds, 119 114 prefsState: prefsState, ··· 274 269 } 275 270 276 271 Future<void> _loadFeed() async { 277 - await _loadFeedInternal(showLoading: _posts.isEmpty); 272 + await _loadFeedInternal(showLoading: _posts.isEmpty, showOfflineFeedback: true); 278 273 } 279 274 280 275 Future<void> _primeFeed() async { ··· 290 285 }); 291 286 } 292 287 293 - await _loadFeedInternal(showLoading: cachedResult == null); 288 + await _loadFeedInternal(showLoading: cachedResult == null, showOfflineFeedback: false); 294 289 } 295 290 296 - Future<void> _loadFeedInternal({required bool showLoading}) async { 291 + Future<void> _loadFeedInternal({required bool showLoading, required bool showOfflineFeedback}) async { 297 292 if (_isLoading) return; 293 + if (context.read<ConnectivityCubit>().state.isOffline) { 294 + if (_posts.isEmpty) { 295 + _setStateIfMounted(() { 296 + _hasError = true; 297 + _errorMessage = offlineActionMessage('refresh your feed'); 298 + _isLoading = false; 299 + _showInitialLoading = false; 300 + }); 301 + } else if (showOfflineFeedback) { 302 + showOfflineSnackBar(context, action: 'refresh your feed'); 303 + } 304 + return; 305 + } 298 306 299 307 _setStateIfMounted(() { 300 308 _isLoading = true; ··· 339 347 340 348 Future<void> _loadMore() async { 341 349 if (_isLoadingMore || _cursor == null) return; 350 + if (context.read<ConnectivityCubit>().state.isOffline) { 351 + return; 352 + } 342 353 343 354 _setStateIfMounted(() => _isLoadingMore = true); 344 355
+48 -24
test/core/widgets/lazurite_app_bar_test.dart
··· 1 1 import 'package:bloc_test/bloc_test.dart'; 2 - import 'package:bluesky/chat_bsky_convo_defs.dart'; 3 2 import 'package:flutter/material.dart'; 4 3 import 'package:flutter_bloc/flutter_bloc.dart'; 5 4 import 'package:flutter_test/flutter_test.dart'; 6 5 import 'package:lazurite/core/router/app_shell.dart'; 6 + import 'package:lazurite/core/theme/app_theme.dart'; 7 7 import 'package:lazurite/core/widgets/lazurite_app_bar.dart'; 8 8 import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 9 + import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; 9 10 import 'package:lazurite/features/auth/data/models/auth_models.dart'; 10 - import 'package:lazurite/features/messages/bloc/convo_list_bloc.dart'; 11 + import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 12 + import 'package:lazurite/features/settings/bloc/settings_state.dart'; 11 13 import 'package:mocktail/mocktail.dart'; 12 14 13 15 class MockAuthBloc extends MockBloc<AuthEvent, AuthState> implements AuthBloc {} 14 16 15 - class MockConvoListBloc extends MockBloc<ConvoListEvent, ConvoListState> implements ConvoListBloc {} 17 + class MockConnectivityCubit extends MockCubit<ConnectivityState> implements ConnectivityCubit {} 18 + 19 + class MockSettingsCubit extends MockCubit<SettingsState> implements SettingsCubit {} 16 20 17 21 void main() { 18 22 late MockAuthBloc authBloc; 19 - late MockConvoListBloc convoListBloc; 23 + late MockConnectivityCubit connectivityCubit; 24 + late MockSettingsCubit settingsCubit; 20 25 21 26 const tokens = AuthTokens( 22 27 accessToken: 'access', ··· 28 33 29 34 setUp(() { 30 35 authBloc = MockAuthBloc(); 31 - convoListBloc = MockConvoListBloc(); 36 + connectivityCubit = MockConnectivityCubit(); 37 + settingsCubit = MockSettingsCubit(); 32 38 when(() => authBloc.state).thenReturn(const AuthState.authenticated(tokens)); 33 39 whenListen(authBloc, const Stream<AuthState>.empty(), initialState: const AuthState.authenticated(tokens)); 34 - when(() => convoListBloc.state).thenReturn(const ConvoListState.loaded(convos: [], cursor: null, hasMore: false)); 40 + when(() => connectivityCubit.state).thenReturn(const ConnectivityState.online()); 35 41 whenListen( 36 - convoListBloc, 37 - const Stream<ConvoListState>.empty(), 38 - initialState: const ConvoListState.loaded(convos: [], cursor: null, hasMore: false), 42 + connectivityCubit, 43 + const Stream<ConnectivityState>.empty(), 44 + initialState: const ConnectivityState.online(), 39 45 ); 46 + when(() => settingsCubit.state).thenReturn( 47 + const SettingsState( 48 + themePalette: AppThemePalette.oxocarbon, 49 + themeVariant: AppThemeVariant.dark, 50 + useSystemTheme: false, 51 + ), 52 + ); 53 + whenListen( 54 + settingsCubit, 55 + const Stream<SettingsState>.empty(), 56 + initialState: const SettingsState( 57 + themePalette: AppThemePalette.oxocarbon, 58 + themeVariant: AppThemeVariant.dark, 59 + useSystemTheme: false, 60 + ), 61 + ); 62 + when(() => settingsCubit.setSimulateOffline(any())).thenAnswer((_) async {}); 40 63 }); 41 64 42 65 Widget buildSubject({ ··· 48 71 return MultiBlocProvider( 49 72 providers: [ 50 73 BlocProvider<AuthBloc>.value(value: authBloc), 51 - BlocProvider<ConvoListBloc>.value(value: convoListBloc), 74 + BlocProvider<ConnectivityCubit>.value(value: connectivityCubit), 75 + BlocProvider<SettingsCubit>.value(value: settingsCubit), 52 76 ], 53 77 child: MaterialApp( 54 78 home: Scaffold( ··· 112 136 buildSubject( 113 137 sectionLabel: 'Home', 114 138 showAvatar: false, 115 - actions: [IconButton(onPressed: () {}, icon: const Icon(Icons.chat_bubble_outline))], 139 + actions: [IconButton(onPressed: () {}, icon: const Icon(Icons.rss_feed))], 116 140 ), 117 141 ); 118 142 await tester.pumpAndSettle(); 119 143 120 - expect(find.byIcon(Icons.chat_bubble_outline), findsOneWidget); 144 + expect(find.byIcon(Icons.rss_feed), findsOneWidget); 121 145 expect(find.text('RT'), findsNothing); 122 146 }); 123 147 124 - testWidgets('AppBarMessagesButton shows unread badge from shared convo list state', (tester) async { 125 - const unreadConvo = ConvoView(id: 'c1', rev: 'rev-1', members: [], muted: false, unreadCount: 3); 126 - when( 127 - () => convoListBloc.state, 128 - ).thenReturn(const ConvoListState.loaded(convos: [unreadConvo], cursor: null, hasMore: false)); 148 + testWidgets('shows simulated offline indicator and lets the user disable it', (tester) async { 149 + when(() => connectivityCubit.state).thenReturn(const ConnectivityState.online(isSimulatedOffline: true)); 129 150 whenListen( 130 - convoListBloc, 131 - const Stream<ConvoListState>.empty(), 132 - initialState: const ConvoListState.loaded(convos: [unreadConvo], cursor: null, hasMore: false), 151 + connectivityCubit, 152 + const Stream<ConnectivityState>.empty(), 153 + initialState: const ConnectivityState.online(isSimulatedOffline: true), 133 154 ); 134 155 135 - await tester.pumpWidget( 136 - buildSubject(sectionLabel: 'Home', showAvatar: false, actions: [const AppBarMessagesButton()]), 137 - ); 156 + await tester.pumpWidget(buildSubject(sectionLabel: 'Home')); 157 + await tester.pumpAndSettle(); 158 + 159 + expect(find.byTooltip('Disable simulated offline mode'), findsAtLeastNWidgets(1)); 160 + 161 + await tester.tap(find.byTooltip('Disable simulated offline mode').first); 138 162 await tester.pump(); 139 163 140 - expect(find.text('3'), findsOneWidget); 164 + verify(() => settingsCubit.setSimulateOffline(false)).called(1); 141 165 }); 142 166 143 167 testWidgets('preferred size height is 64 without bottom widget', (tester) async {
+68 -4
test/features/feed/presentation/home_feed_screen_test.dart
··· 8 8 import 'package:lazurite/core/theme/app_theme.dart'; 9 9 import 'package:lazurite/core/theme/feed_architecture.dart'; 10 10 import 'package:lazurite/core/theme/ui_density.dart'; 11 + import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 12 + import 'package:lazurite/features/auth/data/models/auth_models.dart'; 11 13 import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; 12 14 import 'package:lazurite/features/feed/cubit/feed_preferences_cubit.dart'; 13 15 import 'package:lazurite/features/feed/data/feed_repository.dart'; ··· 24 26 class MockFeedRepository extends Mock implements FeedRepository {} 25 27 26 28 class MockConnectivityCubit extends MockCubit<ConnectivityState> implements ConnectivityCubit {} 29 + 30 + class MockAuthBloc extends MockBloc<AuthEvent, AuthState> implements AuthBloc {} 27 31 28 32 SettingsState _settingsState(FeedArchitecture architecture) => SettingsState( 29 33 themePalette: AppThemePalette.oxocarbon, ··· 72 76 Widget buildHomeSubject({ 73 77 required FeedPreferencesCubit feedPreferencesCubit, 74 78 required FeedRepository feedRepository, 79 + ConnectivityState connectivityState = const ConnectivityState.online(), 75 80 }) { 76 81 final connectivityCubit = MockConnectivityCubit(); 77 - when(() => connectivityCubit.state).thenReturn(const ConnectivityState.online()); 82 + final authBloc = MockAuthBloc(); 83 + when(() => connectivityCubit.state).thenReturn(connectivityState); 84 + whenListen(connectivityCubit, const Stream<ConnectivityState>.empty(), initialState: connectivityState); 85 + when(() => authBloc.state).thenReturn( 86 + const AuthState.authenticated(AuthTokens(accessToken: 'access', did: 'did:plc:test', handle: 'test.bsky.social')), 87 + ); 78 88 whenListen( 79 - connectivityCubit, 80 - const Stream<ConnectivityState>.empty(), 81 - initialState: const ConnectivityState.online(), 89 + authBloc, 90 + const Stream<AuthState>.empty(), 91 + initialState: const AuthState.authenticated( 92 + AuthTokens(accessToken: 'access', did: 'did:plc:test', handle: 'test.bsky.social'), 93 + ), 82 94 ); 83 95 84 96 return MaterialApp( ··· 86 98 value: feedRepository, 87 99 child: MultiBlocProvider( 88 100 providers: [ 101 + BlocProvider<AuthBloc>.value(value: authBloc), 89 102 BlocProvider<FeedPreferencesCubit>.value(value: feedPreferencesCubit), 90 103 BlocProvider<ConnectivityCubit>.value(value: connectivityCubit), 91 104 ], ··· 309 322 }); 310 323 311 324 group('HomeFeedScreen', () { 325 + testWidgets('shows feeds action without the messages shortcut in the app bar', (tester) async { 326 + final feedPreferencesCubit = MockFeedPreferencesCubit(); 327 + final feedRepository = MockFeedRepository(); 328 + final completer = Completer<FeedResult>(); 329 + 330 + when(() => feedPreferencesCubit.state).thenReturn(_homeFeedState); 331 + whenListen(feedPreferencesCubit, const Stream<FeedPreferencesState>.empty(), initialState: _homeFeedState); 332 + when(() => feedRepository.getCachedFeedPage(any())).thenAnswer((_) async => null); 333 + when( 334 + () => feedRepository.getTimeline( 335 + cursor: any(named: 'cursor'), 336 + limit: any(named: 'limit'), 337 + ), 338 + ).thenAnswer((_) => completer.future); 339 + 340 + await tester.pumpWidget( 341 + buildHomeSubject(feedPreferencesCubit: feedPreferencesCubit, feedRepository: feedRepository), 342 + ); 343 + await tester.pump(); 344 + 345 + expect(find.byIcon(Icons.rss_feed), findsOneWidget); 346 + expect(find.byIcon(Icons.chat_bubble_outline), findsNothing); 347 + }); 348 + 312 349 testWidgets('uses a non-default compose hero tag', (tester) async { 313 350 final feedPreferencesCubit = MockFeedPreferencesCubit(); 314 351 final feedRepository = MockFeedRepository(); ··· 364 401 await tester.pump(); 365 402 366 403 expect(errors.where((error) => error.exceptionAsString().contains('setState() called after dispose()')), isEmpty); 404 + }); 405 + 406 + testWidgets('does not fetch the feed when offline and shows an offline message', (tester) async { 407 + final feedPreferencesCubit = MockFeedPreferencesCubit(); 408 + final feedRepository = MockFeedRepository(); 409 + 410 + when(() => feedPreferencesCubit.state).thenReturn(_homeFeedState); 411 + whenListen(feedPreferencesCubit, const Stream<FeedPreferencesState>.empty(), initialState: _homeFeedState); 412 + when(() => feedRepository.getCachedFeedPage(any())).thenAnswer((_) async => null); 413 + 414 + await tester.pumpWidget( 415 + buildHomeSubject( 416 + feedPreferencesCubit: feedPreferencesCubit, 417 + feedRepository: feedRepository, 418 + connectivityState: const ConnectivityState.online(isSimulatedOffline: true), 419 + ), 420 + ); 421 + await tester.pumpAndSettle(); 422 + 423 + expect(find.text('Failed to load feed'), findsOneWidget); 424 + expect(find.textContaining('You\'re offline'), findsOneWidget); 425 + verifyNever( 426 + () => feedRepository.getTimeline( 427 + cursor: any(named: 'cursor'), 428 + limit: any(named: 'limit'), 429 + ), 430 + ); 367 431 }); 368 432 }); 369 433 }