diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index e02de21..33c5cc8 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -28,9 +28,7 @@ import 'package:lazurite/features/notifications/presentation/notifications_scree import 'package:lazurite/features/profile/presentation/profile_screen.dart'; import 'package:lazurite/features/feed/presentation/saved_posts_screen.dart'; import 'package:lazurite/features/search/presentation/search_screen.dart'; -import 'package:lazurite/features/messages/bloc/convo_list_bloc.dart'; import 'package:lazurite/features/messages/bloc/message_bloc.dart'; -import 'package:lazurite/features/messages/cubit/message_unread_count_cubit.dart'; import 'package:lazurite/features/messages/data/convo_repository.dart'; import 'package:lazurite/features/messages/presentation/convo_list_screen.dart'; import 'package:lazurite/features/messages/presentation/message_thread_route_args.dart'; @@ -125,10 +123,7 @@ class AppRouter { GoRoute( path: '/messages', parentNavigatorKey: _rootNavigatorKey, - builder: (context, state) => BlocProvider( - create: (_) => ConvoListBloc(convoRepository: context.read()), - child: const ConvoListScreen(), - ), + builder: (context, state) => const ConvoListScreen(), routes: [ GoRoute( path: ':id', @@ -169,21 +164,7 @@ class AppRouter { log.d('UnreadCountCubit not found, creating new one'); } - MessageUnreadCountCubit? existingMessageCubit; - try { - existingMessageCubit = context.read(); - } catch (_) { - log.d('MessageUnreadCountCubit not found, creating new one'); - } - - ConvoRepository? convoRepository; - try { - convoRepository = context.read(); - } catch (_) { - log.d('ConvoRepository not found, skipping MessageUnreadCountCubit'); - } - - if (existingUnreadCubit != null && (existingMessageCubit != null || convoRepository == null)) { + if (existingUnreadCubit != null) { return AppShell(navigationShell: navigationShell); } @@ -195,8 +176,6 @@ class AppRouter { notificationRepository: NotificationRepository(bluesky: context.read()), ), ), - if (existingMessageCubit == null && convoRepository != null) - BlocProvider(create: (_) => MessageUnreadCountCubit(convoRepository: convoRepository!)), ], child: AppShell(navigationShell: navigationShell), ); diff --git a/lib/core/widgets/lazurite_app_bar.dart b/lib/core/widgets/lazurite_app_bar.dart index deddf29..96c6cdc 100644 --- a/lib/core/widgets/lazurite_app_bar.dart +++ b/lib/core/widgets/lazurite_app_bar.dart @@ -4,7 +4,7 @@ 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/cubit/message_unread_count_cubit.dart'; +import 'package:lazurite/features/messages/bloc/convo_list_bloc.dart'; /// Custom top app bar for the Lazurite shell screens. /// @@ -60,7 +60,7 @@ class AppBarMessagesButton extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); - Widget button = InkWell( + final baseButton = InkWell( borderRadius: BorderRadius.circular(4), onTap: () => GoRouter.maybeOf(context)?.push('/messages'), child: Container( @@ -75,14 +75,15 @@ class AppBarMessagesButton extends StatelessWidget { ); try { - final cubit = context.watch(); - button = BlocBuilder( - bloc: cubit, + 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: state.hasUnread, - label: Text(state.count > 99 ? '99+' : state.count.toString(), style: const TextStyle(fontSize: 10)), - child: button, + isLabelVisible: unreadCount > 0, + label: Text(unreadCount > 99 ? '99+' : unreadCount.toString(), style: const TextStyle(fontSize: 10)), + child: baseButton, ); }, ); @@ -90,7 +91,7 @@ class AppBarMessagesButton extends StatelessWidget { log.d('showing messages button without unread badge'); } - return button; + return baseButton; } } diff --git a/lib/features/messages/cubit/message_unread_count_cubit.dart b/lib/features/messages/cubit/message_unread_count_cubit.dart deleted file mode 100644 index db5ad7a..0000000 --- a/lib/features/messages/cubit/message_unread_count_cubit.dart +++ /dev/null @@ -1,54 +0,0 @@ -import 'dart:async'; - -import 'package:equatable/equatable.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:lazurite/core/logging/app_logger.dart'; -import 'package:lazurite/features/messages/data/convo_repository.dart'; - -class MessageUnreadCountCubit extends Cubit { - MessageUnreadCountCubit({required ConvoRepository convoRepository}) - : _convoRepository = convoRepository, - super(const MessageUnreadCountState(0)) { - _startPolling(); - } - - final ConvoRepository _convoRepository; - Timer? _pollingTimer; - - static const _pollingInterval = Duration(seconds: 30); - - void _startPolling() { - _pollUnreadCount(); - _pollingTimer = Timer.periodic(_pollingInterval, (_) => _pollUnreadCount()); - } - - Future _pollUnreadCount() async { - try { - final count = await _convoRepository.getUnreadCount(); - emit(MessageUnreadCountState(count)); - } catch (_) { - log.w('Failed to poll message unread count'); - } - } - - Future refresh() async { - await _pollUnreadCount(); - } - - @override - Future close() { - _pollingTimer?.cancel(); - return super.close(); - } -} - -class MessageUnreadCountState extends Equatable { - const MessageUnreadCountState(this.count); - - final int count; - - bool get hasUnread => count > 0; - - @override - List get props => [count]; -} diff --git a/lib/features/messages/data/convo_repository.dart b/lib/features/messages/data/convo_repository.dart index 9c731bd..80371a7 100644 --- a/lib/features/messages/data/convo_repository.dart +++ b/lib/features/messages/data/convo_repository.dart @@ -12,19 +12,6 @@ class ConvoRepository { return ConvoListResult(convos: response.data.convos, cursor: response.data.cursor); } - Future getUnreadCount({int limit = 100}) async { - var totalUnread = 0; - String? cursor; - - do { - final result = await listConvos(cursor: cursor, limit: limit); - totalUnread += result.convos.fold(0, (sum, convo) => sum + convo.unreadCount); - cursor = result.cursor; - } while (cursor != null); - - return totalUnread; - } - Future getConvoForMembers(List dids) async { final response = await _chat.convo.getConvoForMembers(members: dids); return response.data.convo; diff --git a/lib/features/messages/presentation/convo_list_screen.dart b/lib/features/messages/presentation/convo_list_screen.dart index e9e7903..d0b1687 100644 --- a/lib/features/messages/presentation/convo_list_screen.dart +++ b/lib/features/messages/presentation/convo_list_screen.dart @@ -25,7 +25,9 @@ class _ConvoListScreenState extends State with SingleTickerProv _tabController = TabController(length: 2, vsync: this); _tabController.addListener(_onTabChanged); _scrollController.addListener(_onScroll); - context.read().add(const ConvosRequested()); + if (context.read().state.status == ConvoListStatus.initial) { + context.read().add(const ConvosRequested()); + } } @override diff --git a/lib/features/messages/presentation/message_thread_screen.dart b/lib/features/messages/presentation/message_thread_screen.dart index 4ed041f..bce9ab8 100644 --- a/lib/features/messages/presentation/message_thread_screen.dart +++ b/lib/features/messages/presentation/message_thread_screen.dart @@ -2,6 +2,7 @@ import 'package:bluesky/chat_bsky_convo_getmessages.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lazurite/features/messages/bloc/convo_list_bloc.dart'; import 'package:lazurite/features/messages/bloc/message_bloc.dart'; import 'package:lazurite/features/messages/presentation/widgets/message_bubble.dart'; @@ -31,6 +32,9 @@ class _MessageThreadScreenState extends State { @override void dispose() { + try { + context.read().add(const ConvosRefreshed()); + } catch (_) {} _scrollController ..removeListener(_onScroll) ..dispose(); diff --git a/lib/main.dart b/lib/main.dart index 035ad67..f4c4dd8 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -10,6 +10,7 @@ import 'package:lazurite/core/logging/logging_navigator_observer.dart'; import 'package:bluesky/bluesky_chat.dart'; import 'package:lazurite/core/network/xrpc_client_factory.dart'; import 'package:lazurite/core/router/app_router.dart'; +import 'package:lazurite/features/messages/bloc/convo_list_bloc.dart'; import 'package:lazurite/features/messages/data/convo_repository.dart'; import 'package:lazurite/core/theme/app_theme.dart'; import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; @@ -130,6 +131,7 @@ class _LazuriteAppState extends State { final searchRepository = SearchRepository(bluesky: bluesky); final postActionRepository = PostActionRepository(bluesky: bluesky); final profileActionRepository = ProfileActionRepository(bluesky: bluesky); + final convoRepository = ConvoRepository(chat: blueskyChat); final accountDid = authState.tokens?.did ?? ''; return MultiBlocProvider( @@ -152,6 +154,9 @@ class _LazuriteAppState extends State { create: (_) => SearchBloc(searchRepository: searchRepository, database: widget.database, accountDid: accountDid), ), + BlocProvider( + create: (_) => ConvoListBloc(convoRepository: convoRepository)..add(const ConvosRequested(limit: 100)), + ), BlocProvider( create: (_) => SavedPostsCubit( database: widget.database, @@ -165,7 +170,7 @@ class _LazuriteAppState extends State { RepositoryProvider(create: (_) => PostActionCache()), RepositoryProvider.value(value: profileActionRepository), RepositoryProvider.value(value: bluesky), - RepositoryProvider(create: (_) => ConvoRepository(chat: blueskyChat)), + RepositoryProvider.value(value: convoRepository), RepositoryProvider.value(value: widget.database), RepositoryProvider.value(value: accountDid), ], diff --git a/test/core/router/app_router_test.dart b/test/core/router/app_router_test.dart index 91cf3f2..fa2265f 100644 --- a/test/core/router/app_router_test.dart +++ b/test/core/router/app_router_test.dart @@ -11,6 +11,7 @@ import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; import 'package:lazurite/features/auth/data/models/auth_models.dart'; import 'package:lazurite/features/feed/bloc/feed_bloc.dart'; import 'package:lazurite/features/feed/cubit/feed_preferences_cubit.dart'; +import 'package:lazurite/features/messages/bloc/convo_list_bloc.dart'; import 'package:lazurite/features/notifications/cubit/unread_count_cubit.dart'; import 'package:lazurite/features/notifications/data/notification_repository.dart'; import 'package:lazurite/features/profile/bloc/profile_bloc.dart'; @@ -30,6 +31,8 @@ class MockSettingsCubit extends MockCubit implements SettingsCubi class MockUnreadCountCubit extends MockCubit implements UnreadCountCubit {} +class MockConvoListBloc extends MockBloc implements ConvoListBloc {} + class MockNotificationRepository extends Mock implements NotificationRepository {} void main() { @@ -39,6 +42,7 @@ void main() { late MockFeedBloc feedBloc; late MockSettingsCubit settingsCubit; late MockUnreadCountCubit unreadCountCubit; + late MockConvoListBloc convoListBloc; late MockNotificationRepository notificationRepository; late StreamController authController; late AuthState currentAuthState; @@ -68,6 +72,7 @@ void main() { feedBloc = MockFeedBloc(); settingsCubit = MockSettingsCubit(); unreadCountCubit = MockUnreadCountCubit(); + convoListBloc = MockConvoListBloc(); notificationRepository = MockNotificationRepository(); authController = StreamController.broadcast(); currentAuthState = const AuthState.authenticated(tokens); @@ -86,6 +91,7 @@ void main() { ), ); when(() => unreadCountCubit.state).thenReturn(const UnreadCountState(0)); + when(() => convoListBloc.state).thenReturn(const ConvoListState.loaded(convos: [], cursor: null, hasMore: false)); when(() => notificationRepository.getUnreadCount()).thenAnswer((_) async => 0); whenListen(authBloc, authController.stream, initialState: currentAuthState); @@ -115,6 +121,11 @@ void main() { ), ); whenListen(unreadCountCubit, const Stream.empty(), initialState: const UnreadCountState(0)); + whenListen( + convoListBloc, + const Stream.empty(), + initialState: const ConvoListState.loaded(convos: [], cursor: null, hasMore: false), + ); }); tearDown(() async { @@ -129,6 +140,7 @@ void main() { BlocProvider.value(value: feedBloc), BlocProvider.value(value: settingsCubit), BlocProvider.value(value: unreadCountCubit), + BlocProvider.value(value: convoListBloc), ], child: RepositoryProvider( create: (_) => notificationRepository, @@ -261,7 +273,10 @@ void main() { return MultiBlocProvider( providers: [BlocProvider.value(value: unreadCountCubit)], - child: RepositoryProvider.value(value: notificationRepository, child: app), + child: MultiBlocProvider( + providers: [BlocProvider.value(value: convoListBloc)], + child: RepositoryProvider.value(value: notificationRepository, child: app), + ), ); }, ), diff --git a/test/core/widgets/lazurite_app_bar_test.dart b/test/core/widgets/lazurite_app_bar_test.dart index 70b38fe..1ffe98f 100644 --- a/test/core/widgets/lazurite_app_bar_test.dart +++ b/test/core/widgets/lazurite_app_bar_test.dart @@ -1,4 +1,5 @@ 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'; @@ -6,12 +7,16 @@ import 'package:lazurite/core/router/app_shell.dart'; import 'package:lazurite/core/widgets/lazurite_app_bar.dart'; import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; import 'package:lazurite/features/auth/data/models/auth_models.dart'; +import 'package:lazurite/features/messages/bloc/convo_list_bloc.dart'; import 'package:mocktail/mocktail.dart'; class MockAuthBloc extends MockBloc implements AuthBloc {} +class MockConvoListBloc extends MockBloc implements ConvoListBloc {} + void main() { late MockAuthBloc authBloc; + late MockConvoListBloc convoListBloc; const tokens = AuthTokens( accessToken: 'access', @@ -23,8 +28,15 @@ void main() { setUp(() { authBloc = MockAuthBloc(); + convoListBloc = MockConvoListBloc(); 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)); + whenListen( + convoListBloc, + const Stream.empty(), + initialState: const ConvoListState.loaded(convos: [], cursor: null, hasMore: false), + ); }); Widget buildSubject({ @@ -33,8 +45,11 @@ void main() { List? actions, bool showAvatar = true, }) { - return BlocProvider.value( - value: authBloc, + return MultiBlocProvider( + providers: [ + BlocProvider.value(value: authBloc), + BlocProvider.value(value: convoListBloc), + ], child: MaterialApp( home: Scaffold( appBar: LazuriteAppBar(sectionLabel: sectionLabel, bottom: bottom, actions: actions, showAvatar: showAvatar), @@ -106,6 +121,25 @@ void main() { 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)); + whenListen( + convoListBloc, + const Stream.empty(), + initialState: const ConvoListState.loaded(convos: [unreadConvo], cursor: null, hasMore: false), + ); + + await tester.pumpWidget( + buildSubject(sectionLabel: 'Home', showAvatar: false, actions: [const AppBarMessagesButton()]), + ); + await tester.pump(); + + expect(find.text('3'), findsOneWidget); + }); + testWidgets('preferred size height is 64 without bottom widget', (tester) async { const bar = LazuriteAppBar(sectionLabel: 'Home'); expect(bar.preferredSize.height, 64); diff --git a/test/features/feed/presentation/home_feed_screen_test.dart b/test/features/feed/presentation/home_feed_screen_test.dart index 2974f5c..d2b39a3 100644 --- a/test/features/feed/presentation/home_feed_screen_test.dart +++ b/test/features/feed/presentation/home_feed_screen_test.dart @@ -125,7 +125,7 @@ void main() { final grid = tester.widget(find.byType(SliverGrid)); final delegate = grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; - final tileWidth = screenWidth; + const tileWidth = screenWidth; expect(delegate.mainAxisExtent, isNotNull); expect(delegate.mainAxisExtent!, greaterThan(tileWidth + 100)); diff --git a/test/features/feed/presentation/post_card_footer_test.dart b/test/features/feed/presentation/post_card_footer_test.dart index 95b7658..9aaf087 100644 --- a/test/features/feed/presentation/post_card_footer_test.dart +++ b/test/features/feed/presentation/post_card_footer_test.dart @@ -137,10 +137,10 @@ void main() { addTearDown(() => FlutterError.onError = previousOnError); await tester.pumpWidget( - MaterialApp( + const MaterialApp( home: Scaffold( body: Center( - child: SizedBox(width: 185.5, child: const PostCardFooter(timestamp: 'SEP 20')), + child: SizedBox(width: 185.5, child: PostCardFooter(timestamp: 'SEP 20')), ), ), ), diff --git a/test/features/messages/cubit/message_unread_count_cubit_test.dart b/test/features/messages/cubit/message_unread_count_cubit_test.dart deleted file mode 100644 index 15d940a..0000000 --- a/test/features/messages/cubit/message_unread_count_cubit_test.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'package:bloc_test/bloc_test.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:lazurite/features/messages/cubit/message_unread_count_cubit.dart'; -import 'package:lazurite/features/messages/data/convo_repository.dart'; -import 'package:mocktail/mocktail.dart'; - -class MockConvoRepository extends Mock implements ConvoRepository {} - -void main() { - late MockConvoRepository mockConvoRepository; - - setUp(() { - mockConvoRepository = MockConvoRepository(); - }); - - group('MessageUnreadCountCubit', () { - blocTest( - 'emits initial unread message count', - build: () { - when(() => mockConvoRepository.getUnreadCount()).thenAnswer((_) async => 4); - return MessageUnreadCountCubit(convoRepository: mockConvoRepository); - }, - expect: () => [const MessageUnreadCountState(4)], - ); - - blocTest( - 'refresh updates unread count', - build: () { - when(() => mockConvoRepository.getUnreadCount()).thenAnswer((_) async => 1); - return MessageUnreadCountCubit(convoRepository: mockConvoRepository); - }, - act: (cubit) async { - await Future.delayed(const Duration(milliseconds: 50)); - when(() => mockConvoRepository.getUnreadCount()).thenAnswer((_) async => 6); - await cubit.refresh(); - }, - expect: () => [const MessageUnreadCountState(1), const MessageUnreadCountState(6)], - ); - - blocTest( - 'silently fails when unread count polling throws', - build: () { - when(() => mockConvoRepository.getUnreadCount()).thenThrow(Exception('network')); - return MessageUnreadCountCubit(convoRepository: mockConvoRepository); - }, - expect: () => [], - ); - }); -}