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 redundant cubit

+80 -156
+2 -23
lib/core/router/app_router.dart
··· 28 28 import 'package:lazurite/features/profile/presentation/profile_screen.dart'; 29 29 import 'package:lazurite/features/feed/presentation/saved_posts_screen.dart'; 30 30 import 'package:lazurite/features/search/presentation/search_screen.dart'; 31 - import 'package:lazurite/features/messages/bloc/convo_list_bloc.dart'; 32 31 import 'package:lazurite/features/messages/bloc/message_bloc.dart'; 33 - import 'package:lazurite/features/messages/cubit/message_unread_count_cubit.dart'; 34 32 import 'package:lazurite/features/messages/data/convo_repository.dart'; 35 33 import 'package:lazurite/features/messages/presentation/convo_list_screen.dart'; 36 34 import 'package:lazurite/features/messages/presentation/message_thread_route_args.dart'; ··· 125 123 GoRoute( 126 124 path: '/messages', 127 125 parentNavigatorKey: _rootNavigatorKey, 128 - builder: (context, state) => BlocProvider( 129 - create: (_) => ConvoListBloc(convoRepository: context.read<ConvoRepository>()), 130 - child: const ConvoListScreen(), 131 - ), 126 + builder: (context, state) => const ConvoListScreen(), 132 127 routes: [ 133 128 GoRoute( 134 129 path: ':id', ··· 169 164 log.d('UnreadCountCubit not found, creating new one'); 170 165 } 171 166 172 - MessageUnreadCountCubit? existingMessageCubit; 173 - try { 174 - existingMessageCubit = context.read<MessageUnreadCountCubit>(); 175 - } catch (_) { 176 - log.d('MessageUnreadCountCubit not found, creating new one'); 177 - } 178 - 179 - ConvoRepository? convoRepository; 180 - try { 181 - convoRepository = context.read<ConvoRepository>(); 182 - } catch (_) { 183 - log.d('ConvoRepository not found, skipping MessageUnreadCountCubit'); 184 - } 185 - 186 - if (existingUnreadCubit != null && (existingMessageCubit != null || convoRepository == null)) { 167 + if (existingUnreadCubit != null) { 187 168 return AppShell(navigationShell: navigationShell); 188 169 } 189 170 ··· 195 176 notificationRepository: NotificationRepository(bluesky: context.read<Bluesky>()), 196 177 ), 197 178 ), 198 - if (existingMessageCubit == null && convoRepository != null) 199 - BlocProvider(create: (_) => MessageUnreadCountCubit(convoRepository: convoRepository!)), 200 179 ], 201 180 child: AppShell(navigationShell: navigationShell), 202 181 );
+10 -9
lib/core/widgets/lazurite_app_bar.dart
··· 4 4 import 'package:lazurite/core/logging/app_logger.dart'; 5 5 import 'package:lazurite/core/router/app_shell.dart'; 6 6 import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 7 - import 'package:lazurite/features/messages/cubit/message_unread_count_cubit.dart'; 7 + import 'package:lazurite/features/messages/bloc/convo_list_bloc.dart'; 8 8 9 9 /// Custom top app bar for the Lazurite shell screens. 10 10 /// ··· 60 60 Widget build(BuildContext context) { 61 61 final theme = Theme.of(context); 62 62 63 - Widget button = InkWell( 63 + final baseButton = InkWell( 64 64 borderRadius: BorderRadius.circular(4), 65 65 onTap: () => GoRouter.maybeOf(context)?.push('/messages'), 66 66 child: Container( ··· 75 75 ); 76 76 77 77 try { 78 - final cubit = context.watch<MessageUnreadCountCubit>(); 79 - button = BlocBuilder<MessageUnreadCountCubit, MessageUnreadCountState>( 80 - bloc: cubit, 78 + final bloc = context.read<ConvoListBloc>(); 79 + return BlocBuilder<ConvoListBloc, ConvoListState>( 80 + bloc: bloc, 81 81 builder: (context, state) { 82 + final unreadCount = state.convos.fold<int>(0, (sum, convo) => sum + convo.unreadCount); 82 83 return Badge( 83 - isLabelVisible: state.hasUnread, 84 - label: Text(state.count > 99 ? '99+' : state.count.toString(), style: const TextStyle(fontSize: 10)), 85 - child: button, 84 + isLabelVisible: unreadCount > 0, 85 + label: Text(unreadCount > 99 ? '99+' : unreadCount.toString(), style: const TextStyle(fontSize: 10)), 86 + child: baseButton, 86 87 ); 87 88 }, 88 89 ); ··· 90 91 log.d('showing messages button without unread badge'); 91 92 } 92 93 93 - return button; 94 + return baseButton; 94 95 } 95 96 } 96 97
-54
lib/features/messages/cubit/message_unread_count_cubit.dart
··· 1 - import 'dart:async'; 2 - 3 - import 'package:equatable/equatable.dart'; 4 - import 'package:flutter_bloc/flutter_bloc.dart'; 5 - import 'package:lazurite/core/logging/app_logger.dart'; 6 - import 'package:lazurite/features/messages/data/convo_repository.dart'; 7 - 8 - class MessageUnreadCountCubit extends Cubit<MessageUnreadCountState> { 9 - MessageUnreadCountCubit({required ConvoRepository convoRepository}) 10 - : _convoRepository = convoRepository, 11 - super(const MessageUnreadCountState(0)) { 12 - _startPolling(); 13 - } 14 - 15 - final ConvoRepository _convoRepository; 16 - Timer? _pollingTimer; 17 - 18 - static const _pollingInterval = Duration(seconds: 30); 19 - 20 - void _startPolling() { 21 - _pollUnreadCount(); 22 - _pollingTimer = Timer.periodic(_pollingInterval, (_) => _pollUnreadCount()); 23 - } 24 - 25 - Future<void> _pollUnreadCount() async { 26 - try { 27 - final count = await _convoRepository.getUnreadCount(); 28 - emit(MessageUnreadCountState(count)); 29 - } catch (_) { 30 - log.w('Failed to poll message unread count'); 31 - } 32 - } 33 - 34 - Future<void> refresh() async { 35 - await _pollUnreadCount(); 36 - } 37 - 38 - @override 39 - Future<void> close() { 40 - _pollingTimer?.cancel(); 41 - return super.close(); 42 - } 43 - } 44 - 45 - class MessageUnreadCountState extends Equatable { 46 - const MessageUnreadCountState(this.count); 47 - 48 - final int count; 49 - 50 - bool get hasUnread => count > 0; 51 - 52 - @override 53 - List<Object?> get props => [count]; 54 - }
-13
lib/features/messages/data/convo_repository.dart
··· 12 12 return ConvoListResult(convos: response.data.convos, cursor: response.data.cursor); 13 13 } 14 14 15 - Future<int> getUnreadCount({int limit = 100}) async { 16 - var totalUnread = 0; 17 - String? cursor; 18 - 19 - do { 20 - final result = await listConvos(cursor: cursor, limit: limit); 21 - totalUnread += result.convos.fold<int>(0, (sum, convo) => sum + convo.unreadCount); 22 - cursor = result.cursor; 23 - } while (cursor != null); 24 - 25 - return totalUnread; 26 - } 27 - 28 15 Future<ConvoView> getConvoForMembers(List<String> dids) async { 29 16 final response = await _chat.convo.getConvoForMembers(members: dids); 30 17 return response.data.convo;
+3 -1
lib/features/messages/presentation/convo_list_screen.dart
··· 25 25 _tabController = TabController(length: 2, vsync: this); 26 26 _tabController.addListener(_onTabChanged); 27 27 _scrollController.addListener(_onScroll); 28 - context.read<ConvoListBloc>().add(const ConvosRequested()); 28 + if (context.read<ConvoListBloc>().state.status == ConvoListStatus.initial) { 29 + context.read<ConvoListBloc>().add(const ConvosRequested()); 30 + } 29 31 } 30 32 31 33 @override
+4
lib/features/messages/presentation/message_thread_screen.dart
··· 2 2 import 'package:flutter/material.dart'; 3 3 import 'package:flutter/services.dart'; 4 4 import 'package:flutter_bloc/flutter_bloc.dart'; 5 + import 'package:lazurite/features/messages/bloc/convo_list_bloc.dart'; 5 6 import 'package:lazurite/features/messages/bloc/message_bloc.dart'; 6 7 import 'package:lazurite/features/messages/presentation/widgets/message_bubble.dart'; 7 8 ··· 31 32 32 33 @override 33 34 void dispose() { 35 + try { 36 + context.read<ConvoListBloc>().add(const ConvosRefreshed()); 37 + } catch (_) {} 34 38 _scrollController 35 39 ..removeListener(_onScroll) 36 40 ..dispose();
+6 -1
lib/main.dart
··· 10 10 import 'package:bluesky/bluesky_chat.dart'; 11 11 import 'package:lazurite/core/network/xrpc_client_factory.dart'; 12 12 import 'package:lazurite/core/router/app_router.dart'; 13 + import 'package:lazurite/features/messages/bloc/convo_list_bloc.dart'; 13 14 import 'package:lazurite/features/messages/data/convo_repository.dart'; 14 15 import 'package:lazurite/core/theme/app_theme.dart'; 15 16 import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; ··· 130 131 final searchRepository = SearchRepository(bluesky: bluesky); 131 132 final postActionRepository = PostActionRepository(bluesky: bluesky); 132 133 final profileActionRepository = ProfileActionRepository(bluesky: bluesky); 134 + final convoRepository = ConvoRepository(chat: blueskyChat); 133 135 final accountDid = authState.tokens?.did ?? ''; 134 136 135 137 return MultiBlocProvider( ··· 153 155 SearchBloc(searchRepository: searchRepository, database: widget.database, accountDid: accountDid), 154 156 ), 155 157 BlocProvider( 158 + create: (_) => ConvoListBloc(convoRepository: convoRepository)..add(const ConvosRequested(limit: 100)), 159 + ), 160 + BlocProvider( 156 161 create: (_) => SavedPostsCubit( 157 162 database: widget.database, 158 163 accountDid: accountDid, ··· 165 170 RepositoryProvider(create: (_) => PostActionCache()), 166 171 RepositoryProvider.value(value: profileActionRepository), 167 172 RepositoryProvider.value(value: bluesky), 168 - RepositoryProvider(create: (_) => ConvoRepository(chat: blueskyChat)), 173 + RepositoryProvider.value(value: convoRepository), 169 174 RepositoryProvider.value(value: widget.database), 170 175 RepositoryProvider.value(value: accountDid), 171 176 ],
+16 -1
test/core/router/app_router_test.dart
··· 11 11 import 'package:lazurite/features/auth/data/models/auth_models.dart'; 12 12 import 'package:lazurite/features/feed/bloc/feed_bloc.dart'; 13 13 import 'package:lazurite/features/feed/cubit/feed_preferences_cubit.dart'; 14 + import 'package:lazurite/features/messages/bloc/convo_list_bloc.dart'; 14 15 import 'package:lazurite/features/notifications/cubit/unread_count_cubit.dart'; 15 16 import 'package:lazurite/features/notifications/data/notification_repository.dart'; 16 17 import 'package:lazurite/features/profile/bloc/profile_bloc.dart'; ··· 29 30 class MockSettingsCubit extends MockCubit<SettingsState> implements SettingsCubit {} 30 31 31 32 class MockUnreadCountCubit extends MockCubit<UnreadCountState> implements UnreadCountCubit {} 33 + 34 + class MockConvoListBloc extends MockBloc<ConvoListEvent, ConvoListState> implements ConvoListBloc {} 32 35 33 36 class MockNotificationRepository extends Mock implements NotificationRepository {} 34 37 ··· 39 42 late MockFeedBloc feedBloc; 40 43 late MockSettingsCubit settingsCubit; 41 44 late MockUnreadCountCubit unreadCountCubit; 45 + late MockConvoListBloc convoListBloc; 42 46 late MockNotificationRepository notificationRepository; 43 47 late StreamController<AuthState> authController; 44 48 late AuthState currentAuthState; ··· 68 72 feedBloc = MockFeedBloc(); 69 73 settingsCubit = MockSettingsCubit(); 70 74 unreadCountCubit = MockUnreadCountCubit(); 75 + convoListBloc = MockConvoListBloc(); 71 76 notificationRepository = MockNotificationRepository(); 72 77 authController = StreamController<AuthState>.broadcast(); 73 78 currentAuthState = const AuthState.authenticated(tokens); ··· 86 91 ), 87 92 ); 88 93 when(() => unreadCountCubit.state).thenReturn(const UnreadCountState(0)); 94 + when(() => convoListBloc.state).thenReturn(const ConvoListState.loaded(convos: [], cursor: null, hasMore: false)); 89 95 when(() => notificationRepository.getUnreadCount()).thenAnswer((_) async => 0); 90 96 91 97 whenListen(authBloc, authController.stream, initialState: currentAuthState); ··· 115 121 ), 116 122 ); 117 123 whenListen(unreadCountCubit, const Stream<UnreadCountState>.empty(), initialState: const UnreadCountState(0)); 124 + whenListen( 125 + convoListBloc, 126 + const Stream<ConvoListState>.empty(), 127 + initialState: const ConvoListState.loaded(convos: [], cursor: null, hasMore: false), 128 + ); 118 129 }); 119 130 120 131 tearDown(() async { ··· 129 140 BlocProvider<FeedBloc>.value(value: feedBloc), 130 141 BlocProvider<SettingsCubit>.value(value: settingsCubit), 131 142 BlocProvider<UnreadCountCubit>.value(value: unreadCountCubit), 143 + BlocProvider<ConvoListBloc>.value(value: convoListBloc), 132 144 ], 133 145 child: RepositoryProvider<NotificationRepository>( 134 146 create: (_) => notificationRepository, ··· 261 273 262 274 return MultiBlocProvider( 263 275 providers: [BlocProvider<UnreadCountCubit>.value(value: unreadCountCubit)], 264 - child: RepositoryProvider<NotificationRepository>.value(value: notificationRepository, child: app), 276 + child: MultiBlocProvider( 277 + providers: [BlocProvider<ConvoListBloc>.value(value: convoListBloc)], 278 + child: RepositoryProvider<NotificationRepository>.value(value: notificationRepository, child: app), 279 + ), 265 280 ); 266 281 }, 267 282 ),
+36 -2
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'; 2 3 import 'package:flutter/material.dart'; 3 4 import 'package:flutter_bloc/flutter_bloc.dart'; 4 5 import 'package:flutter_test/flutter_test.dart'; ··· 6 7 import 'package:lazurite/core/widgets/lazurite_app_bar.dart'; 7 8 import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 8 9 import 'package:lazurite/features/auth/data/models/auth_models.dart'; 10 + import 'package:lazurite/features/messages/bloc/convo_list_bloc.dart'; 9 11 import 'package:mocktail/mocktail.dart'; 10 12 11 13 class MockAuthBloc extends MockBloc<AuthEvent, AuthState> implements AuthBloc {} 12 14 15 + class MockConvoListBloc extends MockBloc<ConvoListEvent, ConvoListState> implements ConvoListBloc {} 16 + 13 17 void main() { 14 18 late MockAuthBloc authBloc; 19 + late MockConvoListBloc convoListBloc; 15 20 16 21 const tokens = AuthTokens( 17 22 accessToken: 'access', ··· 23 28 24 29 setUp(() { 25 30 authBloc = MockAuthBloc(); 31 + convoListBloc = MockConvoListBloc(); 26 32 when(() => authBloc.state).thenReturn(const AuthState.authenticated(tokens)); 27 33 whenListen(authBloc, const Stream<AuthState>.empty(), initialState: const AuthState.authenticated(tokens)); 34 + when(() => convoListBloc.state).thenReturn(const ConvoListState.loaded(convos: [], cursor: null, hasMore: false)); 35 + whenListen( 36 + convoListBloc, 37 + const Stream<ConvoListState>.empty(), 38 + initialState: const ConvoListState.loaded(convos: [], cursor: null, hasMore: false), 39 + ); 28 40 }); 29 41 30 42 Widget buildSubject({ ··· 33 45 List<Widget>? actions, 34 46 bool showAvatar = true, 35 47 }) { 36 - return BlocProvider<AuthBloc>.value( 37 - value: authBloc, 48 + return MultiBlocProvider( 49 + providers: [ 50 + BlocProvider<AuthBloc>.value(value: authBloc), 51 + BlocProvider<ConvoListBloc>.value(value: convoListBloc), 52 + ], 38 53 child: MaterialApp( 39 54 home: Scaffold( 40 55 appBar: LazuriteAppBar(sectionLabel: sectionLabel, bottom: bottom, actions: actions, showAvatar: showAvatar), ··· 104 119 105 120 expect(find.byIcon(Icons.chat_bubble_outline), findsOneWidget); 106 121 expect(find.text('RT'), findsNothing); 122 + }); 123 + 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)); 129 + whenListen( 130 + convoListBloc, 131 + const Stream<ConvoListState>.empty(), 132 + initialState: const ConvoListState.loaded(convos: [unreadConvo], cursor: null, hasMore: false), 133 + ); 134 + 135 + await tester.pumpWidget( 136 + buildSubject(sectionLabel: 'Home', showAvatar: false, actions: [const AppBarMessagesButton()]), 137 + ); 138 + await tester.pump(); 139 + 140 + expect(find.text('3'), findsOneWidget); 107 141 }); 108 142 109 143 testWidgets('preferred size height is 64 without bottom widget', (tester) async {
+1 -1
test/features/feed/presentation/home_feed_screen_test.dart
··· 125 125 126 126 final grid = tester.widget<SliverGrid>(find.byType(SliverGrid)); 127 127 final delegate = grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; 128 - final tileWidth = screenWidth; 128 + const tileWidth = screenWidth; 129 129 130 130 expect(delegate.mainAxisExtent, isNotNull); 131 131 expect(delegate.mainAxisExtent!, greaterThan(tileWidth + 100));
-49
test/features/messages/cubit/message_unread_count_cubit_test.dart
··· 1 - import 'package:bloc_test/bloc_test.dart'; 2 - import 'package:flutter_test/flutter_test.dart'; 3 - import 'package:lazurite/features/messages/cubit/message_unread_count_cubit.dart'; 4 - import 'package:lazurite/features/messages/data/convo_repository.dart'; 5 - import 'package:mocktail/mocktail.dart'; 6 - 7 - class MockConvoRepository extends Mock implements ConvoRepository {} 8 - 9 - void main() { 10 - late MockConvoRepository mockConvoRepository; 11 - 12 - setUp(() { 13 - mockConvoRepository = MockConvoRepository(); 14 - }); 15 - 16 - group('MessageUnreadCountCubit', () { 17 - blocTest<MessageUnreadCountCubit, MessageUnreadCountState>( 18 - 'emits initial unread message count', 19 - build: () { 20 - when(() => mockConvoRepository.getUnreadCount()).thenAnswer((_) async => 4); 21 - return MessageUnreadCountCubit(convoRepository: mockConvoRepository); 22 - }, 23 - expect: () => [const MessageUnreadCountState(4)], 24 - ); 25 - 26 - blocTest<MessageUnreadCountCubit, MessageUnreadCountState>( 27 - 'refresh updates unread count', 28 - build: () { 29 - when(() => mockConvoRepository.getUnreadCount()).thenAnswer((_) async => 1); 30 - return MessageUnreadCountCubit(convoRepository: mockConvoRepository); 31 - }, 32 - act: (cubit) async { 33 - await Future<void>.delayed(const Duration(milliseconds: 50)); 34 - when(() => mockConvoRepository.getUnreadCount()).thenAnswer((_) async => 6); 35 - await cubit.refresh(); 36 - }, 37 - expect: () => [const MessageUnreadCountState(1), const MessageUnreadCountState(6)], 38 - ); 39 - 40 - blocTest<MessageUnreadCountCubit, MessageUnreadCountState>( 41 - 'silently fails when unread count polling throws', 42 - build: () { 43 - when(() => mockConvoRepository.getUnreadCount()).thenThrow(Exception('network')); 44 - return MessageUnreadCountCubit(convoRepository: mockConvoRepository); 45 - }, 46 - expect: () => [], 47 - ); 48 - }); 49 - }