diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index fcc7e29..e02de21 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -30,6 +30,7 @@ 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'; @@ -161,23 +162,41 @@ class AppRouter { return AppShell(navigationShell: navigationShell); } - UnreadCountCubit? existingCubit; + UnreadCountCubit? existingUnreadCubit; try { - existingCubit = context.read(); + existingUnreadCubit = context.read(); } catch (_) { log.d('UnreadCountCubit not found, creating new one'); } - if (existingCubit != null) { + 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)) { return AppShell(navigationShell: navigationShell); } return MultiBlocProvider( providers: [ - BlocProvider( - create: (_) => - UnreadCountCubit(notificationRepository: NotificationRepository(bluesky: context.read())), - ), + if (existingUnreadCubit == null) + BlocProvider( + create: (_) => UnreadCountCubit( + 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 bb8b7a0..deddf29 100644 --- a/lib/core/widgets/lazurite_app_bar.dart +++ b/lib/core/widgets/lazurite_app_bar.dart @@ -1,8 +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/cubit/message_unread_count_cubit.dart'; /// Custom top app bar for the Lazurite shell screens. /// @@ -13,11 +15,12 @@ import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; /// Pass [bottom] to add an additional row below the toolbar (e.g., for the /// home-screen feed-switcher tabs). class LazuriteAppBar extends StatelessWidget implements PreferredSizeWidget { - const LazuriteAppBar({super.key, required this.sectionLabel, this.bottom, this.actions}); + const LazuriteAppBar({super.key, required this.sectionLabel, this.bottom, this.actions, this.showAvatar = true}); final String sectionLabel; final PreferredSizeWidget? bottom; final List? actions; + final bool showAvatar; static const double _toolbarHeight = 64; @@ -40,13 +43,57 @@ class LazuriteAppBar extends StatelessWidget implements PreferredSizeWidget { ), centerTitle: false, titleSpacing: 0, - actions: [...?actions, const _AppBarAvatar(), const SizedBox(width: 8)], + actions: [ + ...?actions, + if (showAvatar) ...[const _AppBarAvatar(), const SizedBox(width: 8)], + ], bottom: bottom, shape: Border(bottom: BorderSide(color: theme.colorScheme.outlineVariant)), ); } } +class AppBarMessagesButton extends StatelessWidget { + const AppBarMessagesButton({super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + Widget button = InkWell( + borderRadius: BorderRadius.circular(4), + onTap: () => GoRouter.maybeOf(context)?.push('/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), + ), + ); + + try { + final cubit = context.watch(); + button = BlocBuilder( + bloc: cubit, + builder: (context, state) { + return Badge( + isLabelVisible: state.hasUnread, + label: Text(state.count > 99 ? '99+' : state.count.toString(), style: const TextStyle(fontSize: 10)), + child: button, + ); + }, + ); + } catch (_) { + log.d('showing messages button without unread badge'); + } + + return button; + } +} + class _AppBarAvatar extends StatelessWidget { const _AppBarAvatar(); diff --git a/lib/features/feed/presentation/home_feed_screen.dart b/lib/features/feed/presentation/home_feed_screen.dart index d28c819..363657f 100644 --- a/lib/features/feed/presentation/home_feed_screen.dart +++ b/lib/features/feed/presentation/home_feed_screen.dart @@ -105,7 +105,12 @@ class _HomeFeedScreenState extends State { return Scaffold( appBar: LazuriteAppBar( sectionLabel: 'Home', - actions: [IconButton(icon: const Icon(Icons.rss_feed), onPressed: () => context.push('/feeds'))], + showAvatar: false, + actions: [ + IconButton(icon: const Icon(Icons.rss_feed), onPressed: () => context.push('/feeds')), + const AppBarMessagesButton(), + const SizedBox(width: 8), + ], bottom: _FeedTabBar( feeds: pinnedFeeds, prefsState: prefsState, diff --git a/lib/features/feed/presentation/widgets/feed_layout_view.dart b/lib/features/feed/presentation/widgets/feed_layout_view.dart index 73afebf..89ce1ec 100644 --- a/lib/features/feed/presentation/widgets/feed_layout_view.dart +++ b/lib/features/feed/presentation/widgets/feed_layout_view.dart @@ -82,7 +82,7 @@ class FeedLayoutView extends StatelessWidget { onRefresh: onRefresh, child: ListView.builder( controller: scrollController, - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + padding: const EdgeInsets.symmetric(vertical: 4), itemCount: itemCount + (isLoadingMore ? 1 : 0), itemBuilder: (context, index) { if (index == itemCount) { @@ -90,7 +90,7 @@ class FeedLayoutView extends StatelessWidget { child: Padding(padding: EdgeInsets.all(16), child: CircularProgressIndicator()), ); } - return Padding(padding: const EdgeInsets.only(bottom: 8), child: linearItemBuilder(context, index)); + return Padding(padding: const EdgeInsets.only(bottom: 4), child: linearItemBuilder(context, index)); }, ), ); diff --git a/lib/features/feed/presentation/widgets/post_card_footer.dart b/lib/features/feed/presentation/widgets/post_card_footer.dart index 73fa7d8..b403e63 100644 --- a/lib/features/feed/presentation/widgets/post_card_footer.dart +++ b/lib/features/feed/presentation/widgets/post_card_footer.dart @@ -37,6 +37,7 @@ class PostCardFooter extends StatelessWidget { this.onLongPressSave, this.onCloudSave, this.onCloudUnsave, + this.showCounts = false, }); final String timestamp; @@ -57,6 +58,7 @@ class PostCardFooter extends StatelessWidget { final VoidCallback? onLongPressSave; final VoidCallback? onCloudSave; final VoidCallback? onCloudUnsave; + final bool showCounts; @override Widget build(BuildContext context) { @@ -79,10 +81,12 @@ class PostCardFooter extends StatelessWidget { activeIcon: Icons.chat_bubble, isActive: false, isLoading: false, + count: replyCount, onTap: onReply, color: colorScheme.onSurfaceVariant, iconSize: iconSize, padding: actionPadding, + showCount: showCounts, ), const SizedBox(width: actionSpacing), _FooterAction( @@ -90,11 +94,13 @@ class PostCardFooter extends StatelessWidget { activeIcon: Icons.repeat, isActive: isReposted, isLoading: isLoadingRepost, + count: repostCount, onTap: onRepost, color: colorScheme.onSurfaceVariant, activeColor: Colors.green, iconSize: iconSize, padding: actionPadding, + showCount: showCounts, ), const SizedBox(width: actionSpacing), _FooterAction( @@ -102,11 +108,13 @@ class PostCardFooter extends StatelessWidget { activeIcon: Icons.favorite, isActive: isLiked, isLoading: isLoadingLike, + count: likeCount, onTap: onLike, color: colorScheme.onSurfaceVariant, activeColor: Colors.pink, iconSize: iconSize, padding: actionPadding, + showCount: showCounts, ), const SizedBox(width: actionSpacing), _FooterAction( @@ -114,12 +122,14 @@ class PostCardFooter extends StatelessWidget { activeIcon: Icons.bookmark, isActive: isSaved, isLoading: false, + count: saveCount, onTap: onSave != null ? () => _showSaveOptions(context) : null, onLongPress: onLongPressSave, color: colorScheme.onSurfaceVariant, activeColor: saveActiveColor, iconSize: iconSize, padding: actionPadding, + showCount: showCounts, ), const SizedBox(width: actionSpacing), Expanded( @@ -193,6 +203,8 @@ class _FooterAction extends StatelessWidget { required this.isLoading, required this.iconSize, required this.padding, + required this.count, + required this.showCount, this.onTap, this.onLongPress, this.color, @@ -205,6 +217,8 @@ class _FooterAction extends StatelessWidget { final bool isLoading; final double iconSize; final double padding; + final int count; + final bool showCount; final VoidCallback? onTap; final VoidCallback? onLongPress; final Color? color; @@ -221,14 +235,34 @@ class _FooterAction extends StatelessWidget { borderRadius: BorderRadius.zero, child: Padding( padding: EdgeInsets.symmetric(horizontal: padding, vertical: padding), - child: isLoading - ? SizedBox( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (isLoading) + SizedBox( width: iconSize, height: iconSize, child: CircularProgressIndicator(strokeWidth: 2, color: iconColor), ) - : Icon(isActive ? activeIcon : icon, size: iconSize, color: iconColor), + else + Icon(isActive ? activeIcon : icon, size: iconSize, color: iconColor), + if (showCount && count > 0) ...[ + const SizedBox(width: 4), + Text(_formatCount(count), style: Theme.of(context).textTheme.bodySmall?.copyWith(color: iconColor)), + ], + ], + ), ), ); } + + String _formatCount(int count) { + if (count >= 1000000) { + return '${(count / 1000000).toStringAsFixed(1)}M'; + } + if (count >= 1000) { + return '${(count / 1000).toStringAsFixed(1)}K'; + } + return '$count'; + } } diff --git a/lib/features/feed/presentation/widgets/post_card_with_actions.dart b/lib/features/feed/presentation/widgets/post_card_with_actions.dart index 86fbb65..a65d708 100644 --- a/lib/features/feed/presentation/widgets/post_card_with_actions.dart +++ b/lib/features/feed/presentation/widgets/post_card_with_actions.dart @@ -146,6 +146,7 @@ class _PostCardWithActionsContent extends StatelessWidget { onLongPressSave: () => unawaited(_onToggleSave(context)), onCloudSave: () => unawaited(_onCloudSave(context)), onCloudUnsave: () => unawaited(_onCloudUnsave(context)), + showCounts: variant == PostCardVariant.linear, ); }, ); diff --git a/lib/features/messages/cubit/message_unread_count_cubit.dart b/lib/features/messages/cubit/message_unread_count_cubit.dart new file mode 100644 index 0000000..db5ad7a --- /dev/null +++ b/lib/features/messages/cubit/message_unread_count_cubit.dart @@ -0,0 +1,54 @@ +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 80371a7..9c731bd 100644 --- a/lib/features/messages/data/convo_repository.dart +++ b/lib/features/messages/data/convo_repository.dart @@ -12,6 +12,19 @@ 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/test/core/widgets/lazurite_app_bar_test.dart b/test/core/widgets/lazurite_app_bar_test.dart index bb1005c..70b38fe 100644 --- a/test/core/widgets/lazurite_app_bar_test.dart +++ b/test/core/widgets/lazurite_app_bar_test.dart @@ -27,12 +27,17 @@ void main() { whenListen(authBloc, const Stream.empty(), initialState: const AuthState.authenticated(tokens)); }); - Widget buildSubject({required String sectionLabel, PreferredSizeWidget? bottom, List? actions}) { + Widget buildSubject({ + required String sectionLabel, + PreferredSizeWidget? bottom, + List? actions, + bool showAvatar = true, + }) { return BlocProvider.value( value: authBloc, child: MaterialApp( home: Scaffold( - appBar: LazuriteAppBar(sectionLabel: sectionLabel, bottom: bottom, actions: actions), + appBar: LazuriteAppBar(sectionLabel: sectionLabel, bottom: bottom, actions: actions, showAvatar: showAvatar), body: const SizedBox.shrink(), ), ), @@ -87,6 +92,20 @@ void main() { expect(find.text('ALERTS'), findsOneWidget); }); + testWidgets('can hide avatar when custom trailing actions are used', (tester) async { + await tester.pumpWidget( + buildSubject( + sectionLabel: 'Home', + showAvatar: false, + actions: [IconButton(onPressed: () {}, icon: const Icon(Icons.chat_bubble_outline))], + ), + ); + await tester.pumpAndSettle(); + + expect(find.byIcon(Icons.chat_bubble_outline), findsOneWidget); + expect(find.text('RT'), findsNothing); + }); + 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 6bcea04..2974f5c 100644 --- a/test/features/feed/presentation/home_feed_screen_test.dart +++ b/test/features/feed/presentation/home_feed_screen_test.dart @@ -147,6 +147,13 @@ void main() { expect(find.text('linear 1'), findsOneWidget); expect(find.text('linear 2'), findsOneWidget); }); + + testWidgets('uses tighter vertical spacing in linear mode', (tester) async { + await tester.pumpWidget(_buildSubject(architecture: FeedArchitecture.linear)); + + final listView = tester.widget(find.byType(ListView)); + expect(listView.padding, const EdgeInsets.symmetric(vertical: 4)); + }); }); group('FeedLayoutView — architecture switching', () { diff --git a/test/features/feed/presentation/post_card_footer_test.dart b/test/features/feed/presentation/post_card_footer_test.dart index 76dd71c..95b7658 100644 --- a/test/features/feed/presentation/post_card_footer_test.dart +++ b/test/features/feed/presentation/post_card_footer_test.dart @@ -51,6 +51,26 @@ void main() { expect(find.byIcon(Icons.bookmark_outline), findsOneWidget); }); + testWidgets('shows action counts when enabled', (tester) async { + await tester.pumpWidget( + _buildSubject( + const PostCardFooter( + timestamp: '1H', + replyCount: 3, + repostCount: 7, + likeCount: 42, + saveCount: 5, + showCounts: true, + ), + ), + ); + + expect(find.text('3'), findsOneWidget); + expect(find.text('7'), findsOneWidget); + expect(find.text('42'), findsOneWidget); + expect(find.text('5'), findsOneWidget); + }); + testWidgets('shows active like icon when isLiked is true', (tester) async { await tester.pumpWidget(_buildSubject(const PostCardFooter(timestamp: '1H', isLiked: true))); expect(find.byIcon(Icons.favorite), findsOneWidget); diff --git a/test/features/messages/cubit/message_unread_count_cubit_test.dart b/test/features/messages/cubit/message_unread_count_cubit_test.dart new file mode 100644 index 0000000..15d940a --- /dev/null +++ b/test/features/messages/cubit/message_unread_count_cubit_test.dart @@ -0,0 +1,49 @@ +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: () => [], + ); + }); +}