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.

feat: message unread count polling, display action counts on post cards, and add avatar visibility control to app bar

+285 -17
+26 -7
lib/core/router/app_router.dart
··· 30 30 import 'package:lazurite/features/search/presentation/search_screen.dart'; 31 31 import 'package:lazurite/features/messages/bloc/convo_list_bloc.dart'; 32 32 import 'package:lazurite/features/messages/bloc/message_bloc.dart'; 33 + import 'package:lazurite/features/messages/cubit/message_unread_count_cubit.dart'; 33 34 import 'package:lazurite/features/messages/data/convo_repository.dart'; 34 35 import 'package:lazurite/features/messages/presentation/convo_list_screen.dart'; 35 36 import 'package:lazurite/features/messages/presentation/message_thread_route_args.dart'; ··· 161 162 return AppShell(navigationShell: navigationShell); 162 163 } 163 164 164 - UnreadCountCubit? existingCubit; 165 + UnreadCountCubit? existingUnreadCubit; 165 166 try { 166 - existingCubit = context.read<UnreadCountCubit>(); 167 + existingUnreadCubit = context.read<UnreadCountCubit>(); 167 168 } catch (_) { 168 169 log.d('UnreadCountCubit not found, creating new one'); 169 170 } 170 171 171 - if (existingCubit != null) { 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)) { 172 187 return AppShell(navigationShell: navigationShell); 173 188 } 174 189 175 190 return MultiBlocProvider( 176 191 providers: [ 177 - BlocProvider( 178 - create: (_) => 179 - UnreadCountCubit(notificationRepository: NotificationRepository(bluesky: context.read<Bluesky>())), 180 - ), 192 + if (existingUnreadCubit == null) 193 + BlocProvider( 194 + create: (_) => UnreadCountCubit( 195 + notificationRepository: NotificationRepository(bluesky: context.read<Bluesky>()), 196 + ), 197 + ), 198 + if (existingMessageCubit == null && convoRepository != null) 199 + BlocProvider(create: (_) => MessageUnreadCountCubit(convoRepository: convoRepository!)), 181 200 ], 182 201 child: AppShell(navigationShell: navigationShell), 183 202 );
+49 -2
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'; 3 4 import 'package:lazurite/core/logging/app_logger.dart'; 4 5 import 'package:lazurite/core/router/app_shell.dart'; 5 6 import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 7 + import 'package:lazurite/features/messages/cubit/message_unread_count_cubit.dart'; 6 8 7 9 /// Custom top app bar for the Lazurite shell screens. 8 10 /// ··· 13 15 /// Pass [bottom] to add an additional row below the toolbar (e.g., for the 14 16 /// home-screen feed-switcher tabs). 15 17 class LazuriteAppBar extends StatelessWidget implements PreferredSizeWidget { 16 - const LazuriteAppBar({super.key, required this.sectionLabel, this.bottom, this.actions}); 18 + const LazuriteAppBar({super.key, required this.sectionLabel, this.bottom, this.actions, this.showAvatar = true}); 17 19 18 20 final String sectionLabel; 19 21 final PreferredSizeWidget? bottom; 20 22 final List<Widget>? actions; 23 + final bool showAvatar; 21 24 22 25 static const double _toolbarHeight = 64; 23 26 ··· 40 43 ), 41 44 centerTitle: false, 42 45 titleSpacing: 0, 43 - actions: [...?actions, const _AppBarAvatar(), const SizedBox(width: 8)], 46 + actions: [ 47 + ...?actions, 48 + if (showAvatar) ...[const _AppBarAvatar(), const SizedBox(width: 8)], 49 + ], 44 50 bottom: bottom, 45 51 shape: Border(bottom: BorderSide(color: theme.colorScheme.outlineVariant)), 46 52 ); 53 + } 54 + } 55 + 56 + class AppBarMessagesButton extends StatelessWidget { 57 + const AppBarMessagesButton({super.key}); 58 + 59 + @override 60 + Widget build(BuildContext context) { 61 + final theme = Theme.of(context); 62 + 63 + Widget button = InkWell( 64 + borderRadius: BorderRadius.circular(4), 65 + onTap: () => GoRouter.maybeOf(context)?.push('/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 + 77 + try { 78 + final cubit = context.watch<MessageUnreadCountCubit>(); 79 + button = BlocBuilder<MessageUnreadCountCubit, MessageUnreadCountState>( 80 + bloc: cubit, 81 + builder: (context, state) { 82 + return Badge( 83 + isLabelVisible: state.hasUnread, 84 + label: Text(state.count > 99 ? '99+' : state.count.toString(), style: const TextStyle(fontSize: 10)), 85 + child: button, 86 + ); 87 + }, 88 + ); 89 + } catch (_) { 90 + log.d('showing messages button without unread badge'); 91 + } 92 + 93 + return button; 47 94 } 48 95 } 49 96
+6 -1
lib/features/feed/presentation/home_feed_screen.dart
··· 105 105 return Scaffold( 106 106 appBar: LazuriteAppBar( 107 107 sectionLabel: 'Home', 108 - actions: [IconButton(icon: const Icon(Icons.rss_feed), onPressed: () => context.push('/feeds'))], 108 + showAvatar: false, 109 + actions: [ 110 + IconButton(icon: const Icon(Icons.rss_feed), onPressed: () => context.push('/feeds')), 111 + const AppBarMessagesButton(), 112 + const SizedBox(width: 8), 113 + ], 109 114 bottom: _FeedTabBar( 110 115 feeds: pinnedFeeds, 111 116 prefsState: prefsState,
+2 -2
lib/features/feed/presentation/widgets/feed_layout_view.dart
··· 82 82 onRefresh: onRefresh, 83 83 child: ListView.builder( 84 84 controller: scrollController, 85 - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), 85 + padding: const EdgeInsets.symmetric(vertical: 4), 86 86 itemCount: itemCount + (isLoadingMore ? 1 : 0), 87 87 itemBuilder: (context, index) { 88 88 if (index == itemCount) { ··· 90 90 child: Padding(padding: EdgeInsets.all(16), child: CircularProgressIndicator()), 91 91 ); 92 92 } 93 - return Padding(padding: const EdgeInsets.only(bottom: 8), child: linearItemBuilder(context, index)); 93 + return Padding(padding: const EdgeInsets.only(bottom: 4), child: linearItemBuilder(context, index)); 94 94 }, 95 95 ), 96 96 );
+37 -3
lib/features/feed/presentation/widgets/post_card_footer.dart
··· 37 37 this.onLongPressSave, 38 38 this.onCloudSave, 39 39 this.onCloudUnsave, 40 + this.showCounts = false, 40 41 }); 41 42 42 43 final String timestamp; ··· 57 58 final VoidCallback? onLongPressSave; 58 59 final VoidCallback? onCloudSave; 59 60 final VoidCallback? onCloudUnsave; 61 + final bool showCounts; 60 62 61 63 @override 62 64 Widget build(BuildContext context) { ··· 79 81 activeIcon: Icons.chat_bubble, 80 82 isActive: false, 81 83 isLoading: false, 84 + count: replyCount, 82 85 onTap: onReply, 83 86 color: colorScheme.onSurfaceVariant, 84 87 iconSize: iconSize, 85 88 padding: actionPadding, 89 + showCount: showCounts, 86 90 ), 87 91 const SizedBox(width: actionSpacing), 88 92 _FooterAction( ··· 90 94 activeIcon: Icons.repeat, 91 95 isActive: isReposted, 92 96 isLoading: isLoadingRepost, 97 + count: repostCount, 93 98 onTap: onRepost, 94 99 color: colorScheme.onSurfaceVariant, 95 100 activeColor: Colors.green, 96 101 iconSize: iconSize, 97 102 padding: actionPadding, 103 + showCount: showCounts, 98 104 ), 99 105 const SizedBox(width: actionSpacing), 100 106 _FooterAction( ··· 102 108 activeIcon: Icons.favorite, 103 109 isActive: isLiked, 104 110 isLoading: isLoadingLike, 111 + count: likeCount, 105 112 onTap: onLike, 106 113 color: colorScheme.onSurfaceVariant, 107 114 activeColor: Colors.pink, 108 115 iconSize: iconSize, 109 116 padding: actionPadding, 117 + showCount: showCounts, 110 118 ), 111 119 const SizedBox(width: actionSpacing), 112 120 _FooterAction( ··· 114 122 activeIcon: Icons.bookmark, 115 123 isActive: isSaved, 116 124 isLoading: false, 125 + count: saveCount, 117 126 onTap: onSave != null ? () => _showSaveOptions(context) : null, 118 127 onLongPress: onLongPressSave, 119 128 color: colorScheme.onSurfaceVariant, 120 129 activeColor: saveActiveColor, 121 130 iconSize: iconSize, 122 131 padding: actionPadding, 132 + showCount: showCounts, 123 133 ), 124 134 const SizedBox(width: actionSpacing), 125 135 Expanded( ··· 193 203 required this.isLoading, 194 204 required this.iconSize, 195 205 required this.padding, 206 + required this.count, 207 + required this.showCount, 196 208 this.onTap, 197 209 this.onLongPress, 198 210 this.color, ··· 205 217 final bool isLoading; 206 218 final double iconSize; 207 219 final double padding; 220 + final int count; 221 + final bool showCount; 208 222 final VoidCallback? onTap; 209 223 final VoidCallback? onLongPress; 210 224 final Color? color; ··· 221 235 borderRadius: BorderRadius.zero, 222 236 child: Padding( 223 237 padding: EdgeInsets.symmetric(horizontal: padding, vertical: padding), 224 - child: isLoading 225 - ? SizedBox( 238 + child: Row( 239 + mainAxisSize: MainAxisSize.min, 240 + children: [ 241 + if (isLoading) 242 + SizedBox( 226 243 width: iconSize, 227 244 height: iconSize, 228 245 child: CircularProgressIndicator(strokeWidth: 2, color: iconColor), 229 246 ) 230 - : Icon(isActive ? activeIcon : icon, size: iconSize, color: iconColor), 247 + else 248 + Icon(isActive ? activeIcon : icon, size: iconSize, color: iconColor), 249 + if (showCount && count > 0) ...[ 250 + const SizedBox(width: 4), 251 + Text(_formatCount(count), style: Theme.of(context).textTheme.bodySmall?.copyWith(color: iconColor)), 252 + ], 253 + ], 254 + ), 231 255 ), 232 256 ); 257 + } 258 + 259 + String _formatCount(int count) { 260 + if (count >= 1000000) { 261 + return '${(count / 1000000).toStringAsFixed(1)}M'; 262 + } 263 + if (count >= 1000) { 264 + return '${(count / 1000).toStringAsFixed(1)}K'; 265 + } 266 + return '$count'; 233 267 } 234 268 }
+1
lib/features/feed/presentation/widgets/post_card_with_actions.dart
··· 146 146 onLongPressSave: () => unawaited(_onToggleSave(context)), 147 147 onCloudSave: () => unawaited(_onCloudSave(context)), 148 148 onCloudUnsave: () => unawaited(_onCloudUnsave(context)), 149 + showCounts: variant == PostCardVariant.linear, 149 150 ); 150 151 }, 151 152 );
+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 + 15 28 Future<ConvoView> getConvoForMembers(List<String> dids) async { 16 29 final response = await _chat.convo.getConvoForMembers(members: dids); 17 30 return response.data.convo;
+21 -2
test/core/widgets/lazurite_app_bar_test.dart
··· 27 27 whenListen(authBloc, const Stream<AuthState>.empty(), initialState: const AuthState.authenticated(tokens)); 28 28 }); 29 29 30 - Widget buildSubject({required String sectionLabel, PreferredSizeWidget? bottom, List<Widget>? actions}) { 30 + Widget buildSubject({ 31 + required String sectionLabel, 32 + PreferredSizeWidget? bottom, 33 + List<Widget>? actions, 34 + bool showAvatar = true, 35 + }) { 31 36 return BlocProvider<AuthBloc>.value( 32 37 value: authBloc, 33 38 child: MaterialApp( 34 39 home: Scaffold( 35 - appBar: LazuriteAppBar(sectionLabel: sectionLabel, bottom: bottom, actions: actions), 40 + appBar: LazuriteAppBar(sectionLabel: sectionLabel, bottom: bottom, actions: actions, showAvatar: showAvatar), 36 41 body: const SizedBox.shrink(), 37 42 ), 38 43 ), ··· 85 90 86 91 expect(find.text('Mark All Read'), findsOneWidget); 87 92 expect(find.text('ALERTS'), findsOneWidget); 93 + }); 94 + 95 + testWidgets('can hide avatar when custom trailing actions are used', (tester) async { 96 + await tester.pumpWidget( 97 + buildSubject( 98 + sectionLabel: 'Home', 99 + showAvatar: false, 100 + actions: [IconButton(onPressed: () {}, icon: const Icon(Icons.chat_bubble_outline))], 101 + ), 102 + ); 103 + await tester.pumpAndSettle(); 104 + 105 + expect(find.byIcon(Icons.chat_bubble_outline), findsOneWidget); 106 + expect(find.text('RT'), findsNothing); 88 107 }); 89 108 90 109 testWidgets('preferred size height is 64 without bottom widget', (tester) async {
+7
test/features/feed/presentation/home_feed_screen_test.dart
··· 147 147 expect(find.text('linear 1'), findsOneWidget); 148 148 expect(find.text('linear 2'), findsOneWidget); 149 149 }); 150 + 151 + testWidgets('uses tighter vertical spacing in linear mode', (tester) async { 152 + await tester.pumpWidget(_buildSubject(architecture: FeedArchitecture.linear)); 153 + 154 + final listView = tester.widget<ListView>(find.byType(ListView)); 155 + expect(listView.padding, const EdgeInsets.symmetric(vertical: 4)); 156 + }); 150 157 }); 151 158 152 159 group('FeedLayoutView — architecture switching', () {
+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 + }