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: combine notifications + messages

+1161 -344
+51 -27
lib/core/router/app_router.dart
··· 9 9 import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 10 10 import 'package:lazurite/core/router/app_shell.dart'; 11 11 import 'package:lazurite/features/auth/presentation/login_screen.dart'; 12 + import 'package:lazurite/features/alerts/presentation/alerts_screen.dart'; 12 13 import 'package:lazurite/features/compose/bloc/compose_bloc.dart'; 13 14 import 'package:lazurite/features/compose/presentation/compose_route_args.dart'; 14 15 import 'package:lazurite/features/compose/presentation/compose_screen.dart'; ··· 24 25 import 'package:lazurite/features/notifications/bloc/notification_bloc.dart'; 25 26 import 'package:lazurite/features/notifications/cubit/unread_count_cubit.dart'; 26 27 import 'package:lazurite/features/notifications/data/notification_repository.dart'; 27 - import 'package:lazurite/features/notifications/presentation/notifications_screen.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 31 import 'package:lazurite/features/messages/bloc/message_bloc.dart'; 32 32 import 'package:lazurite/features/messages/data/convo_repository.dart'; 33 - import 'package:lazurite/features/messages/presentation/convo_list_screen.dart'; 34 33 import 'package:lazurite/features/messages/presentation/message_thread_route_args.dart'; 35 34 import 'package:lazurite/features/messages/presentation/message_thread_screen.dart'; 36 35 import 'package:lazurite/features/moderation/presentation/screens/labeler_detail_screen.dart'; ··· 68 67 }, 69 68 routes: [ 70 69 GoRoute(path: '/login', builder: (context, state) => const LoginScreen()), 70 + GoRoute(path: '/notifications', redirect: (_, _) => '/alerts'), 71 + GoRoute(path: '/messages', redirect: (_, _) => '/alerts/messages'), 71 72 GoRoute( 72 73 path: '/compose', 73 74 parentNavigatorKey: _rootNavigatorKey, ··· 159 160 routes: [ 160 161 GoRoute(path: 'feeds', builder: (context, state) => const FeedManagementScreen()), 161 162 GoRoute( 162 - path: 'messages', 163 - builder: (context, state) => const ConvoListScreen(), 164 - routes: [ 165 - GoRoute( 166 - path: ':id', 167 - builder: (context, state) { 168 - final convoId = state.pathParameters['id']!; 169 - final args = state.extra as MessageThreadRouteArgs?; 170 - return BlocProvider( 171 - create: (_) => MessageBloc( 172 - convoRepository: context.read<ConvoRepository>(), 173 - currentUserDid: context.read<String>(), 174 - ), 175 - child: MessageThreadScreen(convoId: convoId, title: args?.title ?? 'Conversation'), 176 - ); 177 - }, 178 - ), 179 - ], 180 - ), 181 - GoRoute( 182 163 path: 'settings', 183 164 builder: (context, state) => const SettingsScreen(), 184 165 routes: [ ··· 210 191 navigatorKey: _notificationsNavigatorKey, 211 192 routes: [ 212 193 GoRoute( 213 - path: '/notifications', 214 - builder: (context, state) => BlocProvider( 215 - create: (_) => NotificationBloc(notificationRepository: context.read<NotificationRepository>()), 216 - child: const NotificationsScreen(), 217 - ), 194 + path: '/alerts', 195 + builder: (context, state) => _buildAlertsRoute(context, const AlertsScreen()), 196 + routes: [ 197 + GoRoute( 198 + path: 'messages', 199 + builder: (context, state) => 200 + _buildAlertsRoute(context, const AlertsScreen(initialTab: AlertsTab.messages)), 201 + routes: [ 202 + GoRoute( 203 + path: ':id', 204 + builder: (context, state) { 205 + final convoId = state.pathParameters['id']!; 206 + final args = state.extra as MessageThreadRouteArgs?; 207 + return BlocProvider( 208 + create: (_) => MessageBloc( 209 + convoRepository: context.read<ConvoRepository>(), 210 + currentUserDid: context.read<String>(), 211 + ), 212 + child: MessageThreadScreen(convoId: convoId, title: args?.title ?? 'Conversation'), 213 + ); 214 + }, 215 + ), 216 + ], 217 + ), 218 + GoRoute( 219 + path: 'requests', 220 + builder: (context, state) => 221 + _buildAlertsRoute(context, const AlertsScreen(initialTab: AlertsTab.requests)), 222 + ), 223 + ], 218 224 ), 219 225 ], 220 226 ), ··· 238 244 ), 239 245 ], 240 246 ); 247 + 248 + Widget _buildAlertsRoute(BuildContext context, Widget child) { 249 + NotificationBloc? existingNotificationBloc; 250 + try { 251 + existingNotificationBloc = context.read<NotificationBloc>(); 252 + } catch (_) { 253 + log.d('NotificationBloc not found, creating new one for alerts route'); 254 + } 255 + 256 + if (existingNotificationBloc != null) { 257 + return child; 258 + } 259 + 260 + return BlocProvider( 261 + create: (_) => NotificationBloc(notificationRepository: context.read<NotificationRepository>()), 262 + child: child, 263 + ); 264 + } 241 265 } 242 266 243 267 class GoRouterRefreshStream extends ChangeNotifier {
+11 -3
lib/core/router/app_shell.dart
··· 138 138 @override 139 139 Widget build(BuildContext context) { 140 140 final theme = Theme.of(context); 141 + final currentPath = GoRouterState.of(rootContext).uri.path; 142 + final isMessagesRoute = currentPath.startsWith('/alerts/messages') || currentPath.startsWith('/alerts/requests'); 143 + final isNotificationsRoute = currentPath.startsWith('/alerts') && !isMessagesRoute; 141 144 final tokens = rootContext.watch<AuthBloc>().state.tokens; 142 145 final displayName = tokens?.displayName ?? tokens?.handle ?? 'Guest'; 143 146 final handle = tokens?.handle ?? 'Sign in required'; ··· 234 237 icon: Icons.notifications_outlined, 235 238 selectedIcon: Icons.notifications, 236 239 label: 'Notifications', 237 - isSelected: navigationShell.currentIndex == 2, 240 + isSelected: isNotificationsRoute, 238 241 trailing: _notificationsBadge(), 239 - onTap: () => _selectBranch(context, 2), 242 + onTap: () => _goRoute(context, '/alerts'), 240 243 ), 241 244 _MenuTile( 242 245 icon: Icons.chat_bubble_outline, 243 246 selectedIcon: Icons.chat_bubble, 244 247 label: 'Messages', 245 - onTap: () => _pushRoute(context, '/messages'), 248 + isSelected: isMessagesRoute, 249 + onTap: () => _goRoute(context, '/alerts/messages'), 246 250 ), 247 251 _MenuTile( 248 252 icon: Icons.person_outline, ··· 312 316 313 317 void _pushRoute(BuildContext context, String location) { 314 318 _runAfterClose(context, () => GoRouter.of(rootContext).push(location)); 319 + } 320 + 321 + void _goRoute(BuildContext context, String location) { 322 + _runAfterClose(context, () => GoRouter.of(rootContext).go(location)); 315 323 } 316 324 317 325 void _runAfterClose(BuildContext context, VoidCallback action) {
+1 -1
lib/core/widgets/lazurite_app_bar.dart
··· 62 62 63 63 final baseButton = InkWell( 64 64 borderRadius: BorderRadius.circular(4), 65 - onTap: () => GoRouter.maybeOf(context)?.push('/messages'), 65 + onTap: () => GoRouter.maybeOf(context)?.go('/alerts/messages'), 66 66 child: Container( 67 67 width: 32, 68 68 height: 32,
+124
lib/features/alerts/presentation/alerts_screen.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:flutter_bloc/flutter_bloc.dart'; 3 + import 'package:go_router/go_router.dart'; 4 + import 'package:lazurite/core/widgets/lazurite_app_bar.dart'; 5 + import 'package:lazurite/features/messages/bloc/convo_list_bloc.dart'; 6 + import 'package:lazurite/features/messages/presentation/widgets/convo_list_pane.dart'; 7 + import 'package:lazurite/features/notifications/bloc/notification_bloc.dart'; 8 + import 'package:lazurite/features/notifications/cubit/unread_count_cubit.dart'; 9 + import 'package:lazurite/features/notifications/presentation/widgets/notifications_pane.dart'; 10 + 11 + enum AlertsTab { notifications, messages, requests } 12 + 13 + class AlertsScreen extends StatefulWidget { 14 + const AlertsScreen({super.key, this.initialTab = AlertsTab.notifications}); 15 + 16 + final AlertsTab initialTab; 17 + 18 + @override 19 + State<AlertsScreen> createState() => _AlertsScreenState(); 20 + } 21 + 22 + class _AlertsScreenState extends State<AlertsScreen> { 23 + @override 24 + Widget build(BuildContext context) { 25 + final currentTab = widget.initialTab; 26 + 27 + return Scaffold( 28 + appBar: LazuriteAppBar( 29 + sectionLabel: 'Alerts', 30 + actions: currentTab == AlertsTab.notifications 31 + ? [TextButton(onPressed: () => _markAllRead(context), child: const Text('Mark All Read'))] 32 + : null, 33 + bottom: PreferredSize( 34 + preferredSize: const Size.fromHeight(48), 35 + child: _AlertsTabs(currentTab: currentTab), 36 + ), 37 + ), 38 + body: KeyedSubtree(key: ValueKey(currentTab), child: _buildTab(currentTab)), 39 + ); 40 + } 41 + 42 + Widget _buildTab(AlertsTab tab) { 43 + switch (tab) { 44 + case AlertsTab.notifications: 45 + return const NotificationsPane(); 46 + case AlertsTab.messages: 47 + return const ConvoListPane(tab: ConvoTab.primary); 48 + case AlertsTab.requests: 49 + return const ConvoListPane(tab: ConvoTab.requests); 50 + } 51 + } 52 + 53 + void _markAllRead(BuildContext context) { 54 + context.read<NotificationBloc>().add(const NotificationsMarkedRead()); 55 + context.read<UnreadCountCubit>().refresh(); 56 + } 57 + } 58 + 59 + class _AlertsTabs extends StatelessWidget { 60 + const _AlertsTabs({required this.currentTab}); 61 + 62 + final AlertsTab currentTab; 63 + 64 + @override 65 + Widget build(BuildContext context) { 66 + return Container( 67 + decoration: BoxDecoration( 68 + border: Border(bottom: BorderSide(color: Theme.of(context).dividerColor)), 69 + ), 70 + child: Row( 71 + children: [ 72 + _AlertsTabButton(tab: AlertsTab.notifications, label: 'Notifications', currentTab: currentTab), 73 + _AlertsTabButton(tab: AlertsTab.messages, label: 'Messages', currentTab: currentTab), 74 + _AlertsTabButton(tab: AlertsTab.requests, label: 'Requests', currentTab: currentTab), 75 + ], 76 + ), 77 + ); 78 + } 79 + } 80 + 81 + class _AlertsTabButton extends StatelessWidget { 82 + const _AlertsTabButton({required this.tab, required this.label, required this.currentTab}); 83 + 84 + final AlertsTab tab; 85 + final String label; 86 + final AlertsTab currentTab; 87 + 88 + @override 89 + Widget build(BuildContext context) { 90 + final isSelected = currentTab == tab; 91 + final theme = Theme.of(context); 92 + final textStyle = theme.textTheme.bodyLarge?.copyWith( 93 + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, 94 + color: isSelected ? theme.colorScheme.onSurface : theme.colorScheme.onSurfaceVariant, 95 + ); 96 + 97 + return Expanded( 98 + child: InkWell( 99 + onTap: () => _navigateToTab(context), 100 + child: Container( 101 + padding: const EdgeInsets.symmetric(vertical: 12), 102 + decoration: BoxDecoration( 103 + border: Border( 104 + bottom: BorderSide(color: isSelected ? theme.colorScheme.primary : Colors.transparent, width: 2), 105 + ), 106 + ), 107 + child: Text(label, textAlign: TextAlign.center, style: textStyle), 108 + ), 109 + ), 110 + ); 111 + } 112 + 113 + void _navigateToTab(BuildContext context) { 114 + final target = switch (tab) { 115 + AlertsTab.notifications => '/alerts', 116 + AlertsTab.messages => '/alerts/messages', 117 + AlertsTab.requests => '/alerts/requests', 118 + }; 119 + 120 + if (GoRouterState.of(context).uri.path != target) { 121 + context.go(target); 122 + } 123 + } 124 + }
+7 -112
lib/features/messages/presentation/convo_list_screen.dart
··· 1 - import 'package:bluesky/chat_bsky_convo_defs.dart'; 2 1 import 'package:flutter/material.dart'; 3 2 import 'package:flutter_bloc/flutter_bloc.dart'; 4 - import 'package:go_router/go_router.dart'; 5 - import 'package:lazurite/core/logging/app_logger.dart'; 6 3 import 'package:lazurite/core/router/app_shell.dart'; 7 4 import 'package:lazurite/features/messages/bloc/convo_list_bloc.dart'; 8 - import 'package:lazurite/features/messages/presentation/message_thread_route_args.dart'; 9 - import 'package:lazurite/features/messages/presentation/widgets/convo_list_item.dart'; 5 + import 'package:lazurite/features/messages/presentation/widgets/convo_list_pane.dart'; 10 6 11 7 class ConvoListScreen extends StatefulWidget { 12 8 const ConvoListScreen({super.key}); ··· 17 13 18 14 class _ConvoListScreenState extends State<ConvoListScreen> with SingleTickerProviderStateMixin { 19 15 late final TabController _tabController; 20 - final ScrollController _scrollController = ScrollController(); 21 16 22 17 @override 23 18 void initState() { 24 19 super.initState(); 25 20 _tabController = TabController(length: 2, vsync: this); 26 21 _tabController.addListener(_onTabChanged); 27 - _scrollController.addListener(_onScroll); 28 - if (context.read<ConvoListBloc>().state.status == ConvoListStatus.initial) { 29 - context.read<ConvoListBloc>().add(const ConvosRequested()); 30 - } 31 22 } 32 23 33 24 @override ··· 35 26 _tabController 36 27 ..removeListener(_onTabChanged) 37 28 ..dispose(); 38 - _scrollController 39 - ..removeListener(_onScroll) 40 - ..dispose(); 41 29 super.dispose(); 42 30 } 43 31 ··· 45 33 if (_tabController.indexIsChanging) return; 46 34 final tab = _tabController.index == 0 ? ConvoTab.primary : ConvoTab.requests; 47 35 context.read<ConvoListBloc>().add(ConvoTabChanged(tab: tab)); 48 - } 49 - 50 - void _onScroll() { 51 - if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) { 52 - log.d('Pagination not yet implemented'); 53 - } 54 - } 55 - 56 - Future<void> _onRefresh() async => context.read<ConvoListBloc>().add(const ConvosRefreshed()); 57 - 58 - String _currentUserDid(BuildContext context) { 59 - try { 60 - return context.read<String>(); 61 - } catch (_) { 62 - return ''; 63 - } 64 36 } 65 37 66 38 @override ··· 77 49 ], 78 50 ), 79 51 ), 80 - body: BlocBuilder<ConvoListBloc, ConvoListState>( 81 - builder: (context, state) { 82 - if (state.status == ConvoListStatus.initial || 83 - (state.status == ConvoListStatus.loading && state.convos.isEmpty)) { 84 - return const Center(child: CircularProgressIndicator()); 85 - } 86 - 87 - if (state.status == ConvoListStatus.error && state.convos.isEmpty) { 88 - return Center( 89 - child: Column( 90 - mainAxisAlignment: MainAxisAlignment.center, 91 - children: [ 92 - Text('Failed to load messages', style: Theme.of(context).textTheme.titleMedium), 93 - const SizedBox(height: 8), 94 - Text(state.errorMessage ?? 'Unknown error', textAlign: TextAlign.center), 95 - const SizedBox(height: 16), 96 - FilledButton( 97 - onPressed: () => context.read<ConvoListBloc>().add(const ConvosRequested()), 98 - child: const Text('Retry'), 99 - ), 100 - ], 101 - ), 102 - ); 103 - } 104 - 105 - final filtered = _filteredConvos(state.convos, state.activeTab); 106 - 107 - if (filtered.isEmpty) { 108 - return RefreshIndicator( 109 - onRefresh: _onRefresh, 110 - child: ListView( 111 - controller: _scrollController, 112 - children: [ 113 - SizedBox( 114 - height: MediaQuery.of(context).size.height * 0.5, 115 - child: Center( 116 - child: Text( 117 - state.activeTab == ConvoTab.primary ? 'No conversations yet' : 'No message requests', 118 - style: Theme.of(context).textTheme.bodyLarge, 119 - ), 120 - ), 121 - ), 122 - ], 123 - ), 124 - ); 125 - } 126 - 127 - final currentUserDid = _currentUserDid(context); 128 - 129 - return RefreshIndicator( 130 - onRefresh: _onRefresh, 131 - child: ListView.builder( 132 - controller: _scrollController, 133 - itemCount: filtered.length, 134 - itemBuilder: (context, index) { 135 - final convo = filtered[index]; 136 - return ConvoListItem( 137 - convo: convo, 138 - currentUserDid: currentUserDid, 139 - onTap: () => _openThread(context, convo, currentUserDid), 140 - onMuteTap: () { 141 - if (convo.muted) { 142 - context.read<ConvoListBloc>().add(ConvoUnmuted(convoId: convo.id)); 143 - } else { 144 - context.read<ConvoListBloc>().add(ConvoMuted(convoId: convo.id)); 145 - } 146 - }, 147 - ); 148 - }, 149 - ), 150 - ); 151 - }, 52 + body: TabBarView( 53 + controller: _tabController, 54 + children: const [ 55 + ConvoListPane(tab: ConvoTab.primary), 56 + ConvoListPane(tab: ConvoTab.requests), 57 + ], 152 58 ), 153 59 ); 154 - } 155 - 156 - List<ConvoView> _filteredConvos(List<ConvoView> convos, ConvoTab tab) => convos.where((c) { 157 - final isRequest = c.status?.when(knownValue: (data) => data.value == 'request', unknown: (_) => false) ?? false; 158 - return tab == ConvoTab.requests ? isRequest : !isRequest; 159 - }).toList(); 160 - 161 - void _openThread(BuildContext context, ConvoView convo, String currentUserDid) { 162 - final other = convo.members.where((m) => m.did != currentUserDid).firstOrNull; 163 - final title = other?.displayName ?? other?.handle ?? 'Conversation'; 164 - context.push('/messages/${convo.id}', extra: MessageThreadRouteArgs(title: title)); 165 60 } 166 61 }
+157
lib/features/messages/presentation/widgets/convo_list_pane.dart
··· 1 + import 'package:bluesky/chat_bsky_convo_defs.dart'; 2 + import 'package:flutter/material.dart'; 3 + import 'package:flutter_bloc/flutter_bloc.dart'; 4 + import 'package:go_router/go_router.dart'; 5 + import 'package:lazurite/core/logging/app_logger.dart'; 6 + import 'package:lazurite/features/messages/bloc/convo_list_bloc.dart'; 7 + import 'package:lazurite/features/messages/presentation/message_thread_route_args.dart'; 8 + import 'package:lazurite/features/messages/presentation/widgets/convo_list_item.dart'; 9 + 10 + class ConvoListPane extends StatefulWidget { 11 + const ConvoListPane({super.key, required this.tab}); 12 + 13 + final ConvoTab tab; 14 + 15 + @override 16 + State<ConvoListPane> createState() => _ConvoListPaneState(); 17 + } 18 + 19 + class _ConvoListPaneState extends State<ConvoListPane> { 20 + final ScrollController _scrollController = ScrollController(); 21 + 22 + @override 23 + void initState() { 24 + super.initState(); 25 + _scrollController.addListener(_onScroll); 26 + _syncTab(); 27 + if (context.read<ConvoListBloc>().state.status == ConvoListStatus.initial) { 28 + context.read<ConvoListBloc>().add(const ConvosRequested()); 29 + } 30 + } 31 + 32 + @override 33 + void didUpdateWidget(covariant ConvoListPane oldWidget) { 34 + super.didUpdateWidget(oldWidget); 35 + if (oldWidget.tab != widget.tab) { 36 + _syncTab(); 37 + } 38 + } 39 + 40 + @override 41 + void dispose() { 42 + _scrollController 43 + ..removeListener(_onScroll) 44 + ..dispose(); 45 + super.dispose(); 46 + } 47 + 48 + void _syncTab() { 49 + if (context.read<ConvoListBloc>().state.activeTab != widget.tab) { 50 + context.read<ConvoListBloc>().add(ConvoTabChanged(tab: widget.tab)); 51 + } 52 + } 53 + 54 + void _onScroll() { 55 + if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) { 56 + log.d('Pagination not yet implemented'); 57 + } 58 + } 59 + 60 + Future<void> _onRefresh() async => context.read<ConvoListBloc>().add(const ConvosRefreshed()); 61 + 62 + String _currentUserDid(BuildContext context) { 63 + try { 64 + return context.read<String>(); 65 + } catch (_) { 66 + return ''; 67 + } 68 + } 69 + 70 + @override 71 + Widget build(BuildContext context) { 72 + return BlocBuilder<ConvoListBloc, ConvoListState>( 73 + builder: (context, state) { 74 + if (state.status == ConvoListStatus.initial || 75 + (state.status == ConvoListStatus.loading && state.convos.isEmpty)) { 76 + return const Center(child: CircularProgressIndicator()); 77 + } 78 + 79 + if (state.status == ConvoListStatus.error && state.convos.isEmpty) { 80 + return Center( 81 + child: Column( 82 + mainAxisAlignment: MainAxisAlignment.center, 83 + children: [ 84 + Text('Failed to load messages', style: Theme.of(context).textTheme.titleMedium), 85 + const SizedBox(height: 8), 86 + Text(state.errorMessage ?? 'Unknown error', textAlign: TextAlign.center), 87 + const SizedBox(height: 16), 88 + FilledButton( 89 + onPressed: () => context.read<ConvoListBloc>().add(const ConvosRequested()), 90 + child: const Text('Retry'), 91 + ), 92 + ], 93 + ), 94 + ); 95 + } 96 + 97 + final filtered = _filteredConvos(state.convos, widget.tab); 98 + 99 + if (filtered.isEmpty) { 100 + return RefreshIndicator( 101 + onRefresh: _onRefresh, 102 + child: ListView( 103 + controller: _scrollController, 104 + children: [ 105 + SizedBox( 106 + height: MediaQuery.of(context).size.height * 0.5, 107 + child: Center( 108 + child: Text( 109 + widget.tab == ConvoTab.primary ? 'No conversations yet' : 'No message requests', 110 + style: Theme.of(context).textTheme.bodyLarge, 111 + ), 112 + ), 113 + ), 114 + ], 115 + ), 116 + ); 117 + } 118 + 119 + final currentUserDid = _currentUserDid(context); 120 + 121 + return RefreshIndicator( 122 + onRefresh: _onRefresh, 123 + child: ListView.builder( 124 + controller: _scrollController, 125 + itemCount: filtered.length, 126 + itemBuilder: (context, index) { 127 + final convo = filtered[index]; 128 + return ConvoListItem( 129 + convo: convo, 130 + currentUserDid: currentUserDid, 131 + onTap: () => _openThread(context, convo, currentUserDid), 132 + onMuteTap: () { 133 + if (convo.muted) { 134 + context.read<ConvoListBloc>().add(ConvoUnmuted(convoId: convo.id)); 135 + } else { 136 + context.read<ConvoListBloc>().add(ConvoMuted(convoId: convo.id)); 137 + } 138 + }, 139 + ); 140 + }, 141 + ), 142 + ); 143 + }, 144 + ); 145 + } 146 + 147 + List<ConvoView> _filteredConvos(List<ConvoView> convos, ConvoTab tab) => convos.where((c) { 148 + final isRequest = c.status?.when(knownValue: (data) => data.value == 'request', unknown: (_) => false) ?? false; 149 + return tab == ConvoTab.requests ? isRequest : !isRequest; 150 + }).toList(); 151 + 152 + void _openThread(BuildContext context, ConvoView convo, String currentUserDid) { 153 + final other = convo.members.where((m) => m.did != currentUserDid).firstOrNull; 154 + final title = other?.displayName ?? other?.handle ?? 'Conversation'; 155 + context.push('/alerts/messages/${convo.id}', extra: MessageThreadRouteArgs(title: title)); 156 + } 157 + }
+7 -201
lib/features/notifications/presentation/notifications_screen.dart
··· 1 - import 'package:bluesky/app_bsky_notification_listnotifications.dart' as bsky; 2 1 import 'package:flutter/material.dart'; 3 2 import 'package:flutter_bloc/flutter_bloc.dart'; 4 3 import 'package:lazurite/core/widgets/lazurite_app_bar.dart'; 5 4 import 'package:lazurite/features/notifications/bloc/notification_bloc.dart'; 6 5 import 'package:lazurite/features/notifications/cubit/unread_count_cubit.dart'; 7 - import 'package:lazurite/features/notifications/presentation/widgets/notification_list_item.dart'; 6 + import 'package:lazurite/features/notifications/presentation/widgets/notifications_pane.dart'; 8 7 9 - class NotificationsScreen extends StatefulWidget { 8 + class NotificationsScreen extends StatelessWidget { 10 9 const NotificationsScreen({super.key}); 11 10 12 11 @override 13 - State<NotificationsScreen> createState() => _NotificationsScreenState(); 14 - } 15 - 16 - class _NotificationsScreenState extends State<NotificationsScreen> { 17 - final ScrollController _scrollController = ScrollController(); 18 - 19 - @override 20 - void initState() { 21 - super.initState(); 22 - _scrollController.addListener(_onScroll); 23 - context.read<NotificationBloc>().add(const NotificationsRequested()); 24 - context.read<NotificationBloc>().add(const NotificationsMarkedRead()); 25 - context.read<UnreadCountCubit>().refresh(); 26 - } 27 - 28 - @override 29 - void dispose() { 30 - _scrollController.removeListener(_onScroll); 31 - _scrollController.dispose(); 32 - super.dispose(); 33 - } 34 - 35 - void _onScroll() { 36 - if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) { 37 - context.read<NotificationBloc>().add(const NotificationsPageLoaded()); 38 - } 39 - } 40 - 41 - Future<void> _onRefresh() async { 42 - context.read<NotificationBloc>().add(const NotificationsRefreshed()); 43 - context.read<NotificationBloc>().add(const NotificationsMarkedRead()); 44 - await context.read<UnreadCountCubit>().refresh(); 45 - } 46 - 47 - void _markAllRead() { 48 - context.read<NotificationBloc>().add(const NotificationsMarkedRead()); 49 - context.read<UnreadCountCubit>().refresh(); 50 - } 51 - 52 - @override 53 12 Widget build(BuildContext context) { 54 13 return Scaffold( 55 14 appBar: LazuriteAppBar( 56 15 sectionLabel: 'Alerts', 57 - actions: [TextButton(onPressed: _markAllRead, child: const Text('Mark All Read'))], 58 - ), 59 - body: BlocBuilder<NotificationBloc, NotificationState>( 60 - builder: (context, state) { 61 - if (state.status == NotificationStatus.initial || 62 - (state.status == NotificationStatus.loading && state.notifications.isEmpty)) { 63 - return const Center(child: CircularProgressIndicator()); 64 - } 65 - 66 - if (state.status == NotificationStatus.error && state.notifications.isEmpty) { 67 - return Center( 68 - child: Column( 69 - mainAxisAlignment: MainAxisAlignment.center, 70 - children: [ 71 - Text('Failed to load notifications', style: Theme.of(context).textTheme.titleMedium), 72 - const SizedBox(height: 8), 73 - Text(state.errorMessage ?? 'Unknown error', textAlign: TextAlign.center), 74 - const SizedBox(height: 16), 75 - FilledButton( 76 - onPressed: () => context.read<NotificationBloc>().add(const NotificationsRequested()), 77 - child: const Text('Retry'), 78 - ), 79 - ], 80 - ), 81 - ); 82 - } 83 - 84 - if (state.notifications.isEmpty) { 85 - return Center(child: Text('No notifications yet', style: Theme.of(context).textTheme.bodyLarge)); 86 - } 87 - 88 - final groupedNotifications = _groupNotificationsByDay(state.notifications); 89 - 90 - return RefreshIndicator( 91 - onRefresh: _onRefresh, 92 - child: ListView.builder( 93 - controller: _scrollController, 94 - itemCount: _calculateItemCount(groupedNotifications, state), 95 - itemBuilder: (context, index) { 96 - final item = _getItemAtIndex(groupedNotifications, index); 97 - 98 - if (item is String) { 99 - return _DayHeader(title: item); 100 - } else if (item is bsky.Notification) { 101 - return NotificationListItem(notification: item); 102 - } else if (item == null) { 103 - return const Center( 104 - child: Padding(padding: EdgeInsets.all(16), child: CircularProgressIndicator()), 105 - ); 106 - } 107 - 108 - return const SizedBox.shrink(); 109 - }, 110 - ), 111 - ); 112 - }, 16 + actions: [TextButton(onPressed: () => _markAllRead(context), child: const Text('Mark All Read'))], 113 17 ), 18 + body: const NotificationsPane(), 114 19 ); 115 20 } 116 21 117 - Map<DateTime, List<bsky.Notification>> _groupNotificationsByDay(List<bsky.Notification> notifications) { 118 - final grouped = <DateTime, List<bsky.Notification>>{}; 119 - 120 - for (final notification in notifications) { 121 - final date = DateTime(notification.indexedAt.year, notification.indexedAt.month, notification.indexedAt.day); 122 - 123 - grouped.putIfAbsent(date, () => []).add(notification); 124 - } 125 - 126 - return Map.fromEntries(grouped.entries.toList()..sort((a, b) => b.key.compareTo(a.key))); 127 - } 128 - 129 - int _calculateItemCount(Map<DateTime, List<bsky.Notification>> grouped, NotificationState state) { 130 - int count = 0; 131 - for (final entry in grouped.entries) { 132 - count++; 133 - count += entry.value.length; 134 - } 135 - if (state.isLoadingMore) { 136 - count++; 137 - } 138 - return count; 139 - } 140 - 141 - dynamic _getItemAtIndex(Map<DateTime, List<bsky.Notification>> grouped, int index) { 142 - int currentIndex = 0; 143 - 144 - for (final entry in grouped.entries) { 145 - if (currentIndex == index) { 146 - return _formatDayHeader(entry.key); 147 - } 148 - currentIndex++; 149 - 150 - for (final notification in entry.value) { 151 - if (currentIndex == index) { 152 - return notification; 153 - } 154 - currentIndex++; 155 - } 156 - } 157 - 158 - return null; 159 - } 160 - 161 - String _formatDayHeader(DateTime date) { 162 - final now = DateTime.now(); 163 - final today = DateTime(now.year, now.month, now.day); 164 - final yesterday = today.subtract(const Duration(days: 1)); 165 - 166 - if (date == today) { 167 - return 'Today'; 168 - } else if (date == yesterday) { 169 - return 'Yesterday'; 170 - } else { 171 - return _formatDate(date); 172 - } 173 - } 174 - 175 - String _formatDate(DateTime date) { 176 - final months = [ 177 - 'January', 178 - 'February', 179 - 'March', 180 - 'April', 181 - 'May', 182 - 'June', 183 - 'July', 184 - 'August', 185 - 'September', 186 - 'October', 187 - 'November', 188 - 'December', 189 - ]; 190 - 191 - return '${months[date.month - 1]} ${date.day}'; 192 - } 193 - } 194 - 195 - class _DayHeader extends StatelessWidget { 196 - const _DayHeader({required this.title}); 197 - 198 - final String title; 199 - 200 - @override 201 - Widget build(BuildContext context) { 202 - final theme = Theme.of(context); 203 - 204 - return Container( 205 - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), 206 - decoration: BoxDecoration( 207 - color: theme.colorScheme.surface, 208 - border: Border(bottom: BorderSide(color: theme.dividerColor)), 209 - ), 210 - child: Text( 211 - title, 212 - style: theme.textTheme.labelLarge?.copyWith( 213 - fontWeight: FontWeight.w600, 214 - color: theme.colorScheme.onSurfaceVariant, 215 - letterSpacing: 0.5, 216 - ), 217 - ), 218 - ); 22 + void _markAllRead(BuildContext context) { 23 + context.read<NotificationBloc>().add(const NotificationsMarkedRead()); 24 + context.read<UnreadCountCubit>().refresh(); 219 25 } 220 26 }
+335
lib/features/notifications/presentation/widgets/grouped_notification_list_item.dart
··· 1 + import 'package:bluesky/app_bsky_actor_defs.dart' as actor; 2 + import 'package:bluesky/app_bsky_notification_listnotifications.dart' as bsky; 3 + import 'package:bluesky/moderation.dart' as bsky_moderation; 4 + import 'package:flutter/material.dart' hide Notification; 5 + import 'package:go_router/go_router.dart'; 6 + import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; 7 + import 'package:lazurite/features/moderation/presentation/widgets/moderated_avatar.dart'; 8 + import 'package:lazurite/features/moderation/presentation/widgets/moderated_blur_overlay.dart'; 9 + 10 + class NotificationGroup { 11 + const NotificationGroup({required this.notifications}); 12 + 13 + final List<bsky.Notification> notifications; 14 + 15 + bsky.Notification get latest => notifications.first; 16 + 17 + int get count => notifications.length; 18 + 19 + bool get hasUnread => notifications.any((notification) => !notification.isRead); 20 + 21 + List<actor.ProfileView> get authors { 22 + final seen = <String>{}; 23 + final result = <actor.ProfileView>[]; 24 + for (final notification in notifications) { 25 + if (seen.add(notification.author.did)) { 26 + result.add(notification.author); 27 + } 28 + } 29 + return result; 30 + } 31 + } 32 + 33 + class GroupedNotificationListItem extends StatelessWidget { 34 + const GroupedNotificationListItem({super.key, required this.group}); 35 + 36 + final NotificationGroup group; 37 + 38 + @override 39 + Widget build(BuildContext context) { 40 + final theme = Theme.of(context); 41 + final latest = group.latest; 42 + final isUnread = group.hasUnread; 43 + 44 + return InkWell( 45 + onTap: () => _onTap(context), 46 + child: Container( 47 + decoration: BoxDecoration( 48 + border: Border(left: isUnread ? BorderSide(color: theme.colorScheme.primary, width: 3) : BorderSide.none), 49 + color: isUnread ? theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5) : null, 50 + ), 51 + child: Padding( 52 + padding: const EdgeInsets.all(14), 53 + child: Row( 54 + crossAxisAlignment: CrossAxisAlignment.start, 55 + children: [ 56 + _buildReasonIcon(theme, latest), 57 + const SizedBox(width: 12), 58 + Expanded( 59 + child: Column( 60 + crossAxisAlignment: CrossAxisAlignment.start, 61 + children: [ 62 + _buildActorRow(context), 63 + const SizedBox(height: 4), 64 + _buildSummary(theme), 65 + const SizedBox(height: 2), 66 + _buildTime(theme), 67 + if (_shouldShowPreview(latest)) ...[ 68 + const SizedBox(height: 8), 69 + _buildPreview(context, theme, latest), 70 + ], 71 + ], 72 + ), 73 + ), 74 + ], 75 + ), 76 + ), 77 + ), 78 + ); 79 + } 80 + 81 + Widget _buildActorRow(BuildContext context) { 82 + final visibleAuthors = group.authors.take(3).toList(); 83 + 84 + return Row( 85 + children: [ 86 + SizedBox( 87 + width: 28 + ((visibleAuthors.length - 1) * 18), 88 + height: 28, 89 + child: Stack( 90 + children: [ 91 + for (int index = 0; index < visibleAuthors.length; index++) 92 + Positioned(left: index * 18, child: _buildAvatar(context, visibleAuthors[index])), 93 + ], 94 + ), 95 + ), 96 + const SizedBox(width: 8), 97 + Container( 98 + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), 99 + decoration: BoxDecoration( 100 + color: Theme.of(context).colorScheme.surfaceContainerHighest, 101 + borderRadius: BorderRadius.circular(999), 102 + ), 103 + child: Text( 104 + '${group.count}', 105 + style: Theme.of(context).textTheme.labelMedium?.copyWith(fontWeight: FontWeight.w700), 106 + ), 107 + ), 108 + ], 109 + ); 110 + } 111 + 112 + Widget _buildAvatar(BuildContext context, actor.ProfileView author) { 113 + final moderationService = maybeModerationService(context); 114 + final avatarUi = 115 + moderationService?.profileUi(author, bsky_moderation.ModerationBehaviorContext.avatar) ?? 116 + const bsky_moderation.ModerationUI(); 117 + 118 + return DecoratedBox( 119 + decoration: BoxDecoration( 120 + shape: BoxShape.circle, 121 + border: Border.all(color: Theme.of(context).colorScheme.surface, width: 2), 122 + ), 123 + child: ModeratedAvatar( 124 + size: 28, 125 + ui: avatarUi, 126 + imageUrl: author.avatar, 127 + initials: _getInitials(author.displayName ?? author.handle), 128 + shape: BoxShape.circle, 129 + placeholderTextStyle: const TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: Colors.black54), 130 + ), 131 + ); 132 + } 133 + 134 + Widget _buildReasonIcon(ThemeData theme, bsky.Notification notification) { 135 + final reason = notification.reason; 136 + final colorScheme = theme.colorScheme; 137 + 138 + Color backgroundColor; 139 + Color iconColor; 140 + IconData iconData; 141 + 142 + if (reason.isKnownValue) { 143 + switch (reason.knownValue) { 144 + case bsky.KnownNotificationReason.like: 145 + backgroundColor = colorScheme.error.withValues(alpha: 0.1); 146 + iconColor = colorScheme.error; 147 + iconData = Icons.favorite; 148 + case bsky.KnownNotificationReason.repost: 149 + backgroundColor = Colors.green.withValues(alpha: 0.1); 150 + iconColor = Colors.green; 151 + iconData = Icons.repeat; 152 + case bsky.KnownNotificationReason.follow: 153 + backgroundColor = colorScheme.primary.withValues(alpha: 0.1); 154 + iconColor = colorScheme.primary; 155 + iconData = Icons.person_add; 156 + case bsky.KnownNotificationReason.reply: 157 + backgroundColor = colorScheme.secondary.withValues(alpha: 0.1); 158 + iconColor = colorScheme.secondary; 159 + iconData = Icons.chat_bubble; 160 + case bsky.KnownNotificationReason.mention: 161 + backgroundColor = colorScheme.primary.withValues(alpha: 0.1); 162 + iconColor = colorScheme.primary; 163 + iconData = Icons.alternate_email; 164 + case bsky.KnownNotificationReason.quote: 165 + backgroundColor = Colors.purple.withValues(alpha: 0.1); 166 + iconColor = Colors.purple; 167 + iconData = Icons.format_quote; 168 + default: 169 + backgroundColor = colorScheme.surfaceContainerHighest; 170 + iconColor = colorScheme.onSurfaceVariant; 171 + iconData = Icons.notifications; 172 + } 173 + } else { 174 + backgroundColor = colorScheme.surfaceContainerHighest; 175 + iconColor = colorScheme.onSurfaceVariant; 176 + iconData = Icons.notifications; 177 + } 178 + 179 + return Container( 180 + width: 32, 181 + height: 32, 182 + decoration: BoxDecoration(color: backgroundColor, shape: BoxShape.circle), 183 + child: Icon(iconData, size: 16, color: iconColor), 184 + ); 185 + } 186 + 187 + Widget _buildSummary(ThemeData theme) { 188 + return RichText( 189 + text: TextSpan( 190 + children: [ 191 + TextSpan( 192 + text: _actorSummary(), 193 + style: theme.textTheme.bodyMedium?.copyWith( 194 + fontWeight: FontWeight.w600, 195 + color: theme.colorScheme.onSurface, 196 + ), 197 + ), 198 + TextSpan( 199 + text: ' ${_getReasonText(group.latest)}', 200 + style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant), 201 + ), 202 + ], 203 + ), 204 + ); 205 + } 206 + 207 + String _actorSummary() { 208 + final names = group.authors.map((author) => author.displayName ?? author.handle).toList(); 209 + if (names.isEmpty) { 210 + return 'Someone'; 211 + } 212 + if (names.length == 1) { 213 + return names.first; 214 + } 215 + if (names.length == 2) { 216 + return '${names[0]} and ${names[1]}'; 217 + } 218 + return '${names[0]}, ${names[1]}, and ${names.length - 2} others'; 219 + } 220 + 221 + Widget _buildTime(ThemeData theme) { 222 + return Text( 223 + _formatTime(group.latest.indexedAt), 224 + style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), 225 + ); 226 + } 227 + 228 + String _getReasonText(bsky.Notification notification) { 229 + final reason = notification.reason; 230 + 231 + if (reason.isKnownValue) { 232 + switch (reason.knownValue) { 233 + case bsky.KnownNotificationReason.like: 234 + return 'liked your post'; 235 + case bsky.KnownNotificationReason.repost: 236 + return 'reposted your post'; 237 + case bsky.KnownNotificationReason.follow: 238 + return 'followed you'; 239 + case bsky.KnownNotificationReason.mention: 240 + return 'mentioned you'; 241 + case bsky.KnownNotificationReason.reply: 242 + return 'replied to your post'; 243 + case bsky.KnownNotificationReason.quote: 244 + return 'quoted your post'; 245 + default: 246 + return 'interacted with you'; 247 + } 248 + } 249 + 250 + return 'interacted with you'; 251 + } 252 + 253 + String _formatTime(DateTime time) { 254 + final now = DateTime.now(); 255 + final difference = now.difference(time); 256 + 257 + if (difference.inMinutes < 1) { 258 + return 'Just now'; 259 + } else if (difference.inMinutes < 60) { 260 + return '${difference.inMinutes}m ago'; 261 + } else if (difference.inHours < 24) { 262 + return '${difference.inHours}h ago'; 263 + } else if (difference.inDays < 7) { 264 + return '${difference.inDays}d ago'; 265 + } 266 + 267 + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; 268 + 269 + return '${months[time.month - 1]} ${time.day}'; 270 + } 271 + 272 + bool _shouldShowPreview(bsky.Notification notification) { 273 + final reason = notification.reason; 274 + if (reason.isKnownValue) { 275 + return reason.knownValue != bsky.KnownNotificationReason.follow; 276 + } 277 + return false; 278 + } 279 + 280 + Widget _buildPreview(BuildContext context, ThemeData theme, bsky.Notification notification) { 281 + final text = notification.record['text'] as String?; 282 + final moderationService = maybeModerationService(context); 283 + final notificationUi = 284 + moderationService?.notificationUi(notification, bsky_moderation.ModerationBehaviorContext.contentList) ?? 285 + const bsky_moderation.ModerationUI(); 286 + 287 + if (text == null || text.isEmpty) { 288 + return const SizedBox.shrink(); 289 + } 290 + 291 + return ModeratedBlurOverlay( 292 + ui: notificationUi, 293 + borderRadius: BorderRadius.circular(8), 294 + child: Container( 295 + padding: const EdgeInsets.all(10), 296 + decoration: BoxDecoration( 297 + color: theme.colorScheme.surfaceContainerHighest, 298 + borderRadius: BorderRadius.circular(8), 299 + border: Border.all(color: theme.dividerColor), 300 + ), 301 + child: Text( 302 + text, 303 + maxLines: 2, 304 + overflow: TextOverflow.ellipsis, 305 + style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), 306 + ), 307 + ), 308 + ); 309 + } 310 + 311 + String _getInitials(String text) { 312 + final parts = text.split(' '); 313 + if (parts.length >= 2) { 314 + return '${parts[0][0]}${parts[1][0]}'.toUpperCase(); 315 + } 316 + return text.substring(0, text.length >= 2 ? 2 : 1).toUpperCase(); 317 + } 318 + 319 + void _onTap(BuildContext context) { 320 + final notification = group.latest; 321 + final reason = notification.reason; 322 + 323 + if (reason.isKnownValue && reason.knownValue == bsky.KnownNotificationReason.follow) { 324 + context.push('/profile/view?actor=${notification.author.did}'); 325 + return; 326 + } 327 + 328 + final isLikeOrRepost = 329 + reason.isKnownValue && 330 + (reason.knownValue == bsky.KnownNotificationReason.like || 331 + reason.knownValue == bsky.KnownNotificationReason.repost); 332 + final uri = isLikeOrRepost ? (notification.reasonSubject ?? notification.uri) : notification.uri; 333 + context.push('/post?uri=${Uri.encodeComponent(uri.toString())}'); 334 + } 335 + }
+242
lib/features/notifications/presentation/widgets/notifications_pane.dart
··· 1 + import 'package:bluesky/app_bsky_notification_listnotifications.dart' as bsky; 2 + import 'package:flutter/material.dart'; 3 + import 'package:flutter_bloc/flutter_bloc.dart'; 4 + import 'package:lazurite/features/notifications/bloc/notification_bloc.dart'; 5 + import 'package:lazurite/features/notifications/cubit/unread_count_cubit.dart'; 6 + import 'package:lazurite/features/notifications/presentation/widgets/grouped_notification_list_item.dart'; 7 + import 'package:lazurite/features/notifications/presentation/widgets/notification_list_item.dart'; 8 + 9 + class NotificationsPane extends StatefulWidget { 10 + const NotificationsPane({super.key}); 11 + 12 + @override 13 + State<NotificationsPane> createState() => _NotificationsPaneState(); 14 + } 15 + 16 + class _NotificationsPaneState extends State<NotificationsPane> { 17 + final ScrollController _scrollController = ScrollController(); 18 + 19 + @override 20 + void initState() { 21 + super.initState(); 22 + _scrollController.addListener(_onScroll); 23 + if (context.read<NotificationBloc>().state.status == NotificationStatus.initial) { 24 + context.read<NotificationBloc>().add(const NotificationsRequested()); 25 + context.read<NotificationBloc>().add(const NotificationsMarkedRead()); 26 + context.read<UnreadCountCubit>().refresh(); 27 + } 28 + } 29 + 30 + @override 31 + void dispose() { 32 + _scrollController 33 + ..removeListener(_onScroll) 34 + ..dispose(); 35 + super.dispose(); 36 + } 37 + 38 + void _onScroll() { 39 + if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) { 40 + context.read<NotificationBloc>().add(const NotificationsPageLoaded()); 41 + } 42 + } 43 + 44 + Future<void> _onRefresh() async { 45 + context.read<NotificationBloc>().add(const NotificationsRefreshed()); 46 + context.read<NotificationBloc>().add(const NotificationsMarkedRead()); 47 + await context.read<UnreadCountCubit>().refresh(); 48 + } 49 + 50 + @override 51 + Widget build(BuildContext context) { 52 + return BlocBuilder<NotificationBloc, NotificationState>( 53 + builder: (context, state) { 54 + if (state.status == NotificationStatus.initial || 55 + (state.status == NotificationStatus.loading && state.notifications.isEmpty)) { 56 + return const Center(child: CircularProgressIndicator()); 57 + } 58 + 59 + if (state.status == NotificationStatus.error && state.notifications.isEmpty) { 60 + return Center( 61 + child: Column( 62 + mainAxisAlignment: MainAxisAlignment.center, 63 + children: [ 64 + Text('Failed to load notifications', style: Theme.of(context).textTheme.titleMedium), 65 + const SizedBox(height: 8), 66 + Text(state.errorMessage ?? 'Unknown error', textAlign: TextAlign.center), 67 + const SizedBox(height: 16), 68 + FilledButton( 69 + onPressed: () => context.read<NotificationBloc>().add(const NotificationsRequested()), 70 + child: const Text('Retry'), 71 + ), 72 + ], 73 + ), 74 + ); 75 + } 76 + 77 + if (state.notifications.isEmpty) { 78 + return Center(child: Text('No notifications yet', style: Theme.of(context).textTheme.bodyLarge)); 79 + } 80 + 81 + final groupedNotifications = _groupNotificationsByDay(state.notifications); 82 + 83 + return RefreshIndicator( 84 + onRefresh: _onRefresh, 85 + child: ListView.builder( 86 + controller: _scrollController, 87 + itemCount: _calculateItemCount(groupedNotifications, state), 88 + itemBuilder: (context, index) { 89 + final item = _getItemAtIndex(groupedNotifications, index); 90 + 91 + if (item is String) { 92 + return _DayHeader(title: item); 93 + } else if (item is NotificationGroup) { 94 + if (item.count == 1) { 95 + return NotificationListItem(notification: item.latest); 96 + } 97 + return GroupedNotificationListItem(group: item); 98 + } else if (item == null) { 99 + return const Center( 100 + child: Padding(padding: EdgeInsets.all(16), child: CircularProgressIndicator()), 101 + ); 102 + } 103 + 104 + return const SizedBox.shrink(); 105 + }, 106 + ), 107 + ); 108 + }, 109 + ); 110 + } 111 + 112 + Map<DateTime, List<NotificationGroup>> _groupNotificationsByDay(List<bsky.Notification> notifications) { 113 + final grouped = <DateTime, Map<String, List<bsky.Notification>>>{}; 114 + final sorted = [...notifications]..sort((a, b) => b.indexedAt.compareTo(a.indexedAt)); 115 + 116 + for (final notification in sorted) { 117 + final date = DateTime(notification.indexedAt.year, notification.indexedAt.month, notification.indexedAt.day); 118 + final groupKey = _groupKey(notification); 119 + 120 + grouped.putIfAbsent(date, () => <String, List<bsky.Notification>>{}); 121 + grouped[date]!.putIfAbsent(groupKey, () => <bsky.Notification>[]).add(notification); 122 + } 123 + 124 + final sortedDays = grouped.keys.toList()..sort((a, b) => b.compareTo(a)); 125 + final result = <DateTime, List<NotificationGroup>>{}; 126 + 127 + for (final day in sortedDays) { 128 + final groups = 129 + grouped[day]!.values.map((notifications) => NotificationGroup(notifications: notifications)).toList() 130 + ..sort((a, b) => b.latest.indexedAt.compareTo(a.latest.indexedAt)); 131 + result[day] = groups; 132 + } 133 + 134 + return result; 135 + } 136 + 137 + String _groupKey(bsky.Notification notification) { 138 + if (notification.reason.isKnownValue) { 139 + final reason = notification.reason.knownValue; 140 + if (reason == bsky.KnownNotificationReason.follow) { 141 + return 'follow'; 142 + } 143 + 144 + final subject = notification.reasonSubject?.toString() ?? notification.uri.toString(); 145 + return '${reason.toString()}:$subject'; 146 + } 147 + 148 + return 'unknown:${notification.reasonSubject ?? notification.uri}'; 149 + } 150 + 151 + int _calculateItemCount(Map<DateTime, List<NotificationGroup>> grouped, NotificationState state) { 152 + int count = 0; 153 + for (final entry in grouped.entries) { 154 + count++; 155 + count += entry.value.length; 156 + } 157 + if (state.isLoadingMore) { 158 + count++; 159 + } 160 + return count; 161 + } 162 + 163 + dynamic _getItemAtIndex(Map<DateTime, List<NotificationGroup>> grouped, int index) { 164 + int currentIndex = 0; 165 + 166 + for (final entry in grouped.entries) { 167 + if (currentIndex == index) { 168 + return _formatDayHeader(entry.key); 169 + } 170 + currentIndex++; 171 + 172 + for (final notificationGroup in entry.value) { 173 + if (currentIndex == index) { 174 + return notificationGroup; 175 + } 176 + currentIndex++; 177 + } 178 + } 179 + 180 + return null; 181 + } 182 + 183 + String _formatDayHeader(DateTime date) { 184 + final now = DateTime.now(); 185 + final today = DateTime(now.year, now.month, now.day); 186 + final yesterday = today.subtract(const Duration(days: 1)); 187 + 188 + if (date == today) { 189 + return 'Today'; 190 + } else if (date == yesterday) { 191 + return 'Yesterday'; 192 + } 193 + 194 + return _formatDate(date); 195 + } 196 + 197 + String _formatDate(DateTime date) { 198 + const months = [ 199 + 'January', 200 + 'February', 201 + 'March', 202 + 'April', 203 + 'May', 204 + 'June', 205 + 'July', 206 + 'August', 207 + 'September', 208 + 'October', 209 + 'November', 210 + 'December', 211 + ]; 212 + 213 + return '${months[date.month - 1]} ${date.day}'; 214 + } 215 + } 216 + 217 + class _DayHeader extends StatelessWidget { 218 + const _DayHeader({required this.title}); 219 + 220 + final String title; 221 + 222 + @override 223 + Widget build(BuildContext context) { 224 + final theme = Theme.of(context); 225 + 226 + return Container( 227 + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), 228 + decoration: BoxDecoration( 229 + color: theme.colorScheme.surface, 230 + border: Border(bottom: BorderSide(color: theme.dividerColor)), 231 + ), 232 + child: Text( 233 + title, 234 + style: theme.textTheme.labelLarge?.copyWith( 235 + fontWeight: FontWeight.w600, 236 + color: theme.colorScheme.onSurfaceVariant, 237 + letterSpacing: 0.5, 238 + ), 239 + ), 240 + ); 241 + } 242 + }
+1
test/core/router/app_router_test.dart
··· 217 217 await tester.tap(find.byTooltip('Open menu')); 218 218 await tester.pumpAndSettle(); 219 219 220 + expect(find.text('Notifications'), findsOneWidget); 220 221 expect(find.text('Messages'), findsOneWidget); 221 222 expect(find.text('Settings'), findsOneWidget); 222 223 });
+186
test/features/alerts/presentation/alerts_screen_test.dart
··· 1 + import 'package:atproto_core/atproto_core.dart'; 2 + import 'package:bluesky/app_bsky_actor_defs.dart' as app_actor; 3 + import 'package:bluesky/app_bsky_notification_listnotifications.dart' as bsky; 4 + import 'package:bluesky/chat_bsky_actor_defs.dart' as chat_actor; 5 + import 'package:bluesky/chat_bsky_convo_defs.dart'; 6 + import 'package:flutter/material.dart'; 7 + import 'package:flutter_bloc/flutter_bloc.dart'; 8 + import 'package:flutter_test/flutter_test.dart'; 9 + import 'package:go_router/go_router.dart'; 10 + import 'package:lazurite/features/alerts/presentation/alerts_screen.dart'; 11 + import 'package:lazurite/features/messages/bloc/convo_list_bloc.dart'; 12 + import 'package:lazurite/features/messages/data/convo_repository.dart'; 13 + import 'package:lazurite/features/notifications/bloc/notification_bloc.dart'; 14 + import 'package:lazurite/features/notifications/cubit/unread_count_cubit.dart'; 15 + import 'package:lazurite/features/notifications/data/notification_repository.dart'; 16 + import 'package:mocktail/mocktail.dart'; 17 + 18 + class MockNotificationRepository extends Mock implements NotificationRepository {} 19 + 20 + class MockConvoRepository extends Mock implements ConvoRepository {} 21 + 22 + void main() { 23 + late MockNotificationRepository notificationRepository; 24 + late MockConvoRepository convoRepository; 25 + 26 + setUp(() { 27 + notificationRepository = MockNotificationRepository(); 28 + convoRepository = MockConvoRepository(); 29 + 30 + when( 31 + () => notificationRepository.listNotifications( 32 + cursor: any(named: 'cursor'), 33 + limit: any(named: 'limit'), 34 + ), 35 + ).thenAnswer( 36 + (_) async => NotificationListResult( 37 + notifications: [ 38 + bsky.Notification( 39 + uri: AtUri.parse('at://did:plc:alice/app.bsky.feed.post/abc'), 40 + cid: 'cid-123', 41 + author: const app_actor.ProfileView(did: 'did:plc:alice', handle: 'alice.bsky.social'), 42 + reason: const bsky.NotificationReason.knownValue(data: bsky.KnownNotificationReason.like), 43 + record: {'text': 'Test post'}, 44 + isRead: false, 45 + indexedAt: DateTime.now(), 46 + ), 47 + ], 48 + cursor: null, 49 + ), 50 + ); 51 + when(() => notificationRepository.getUnreadCount()).thenAnswer((_) async => 1); 52 + when(() => notificationRepository.updateSeen()).thenAnswer((_) async {}); 53 + 54 + when( 55 + () => convoRepository.listConvos( 56 + cursor: any(named: 'cursor'), 57 + limit: any(named: 'limit'), 58 + ), 59 + ).thenAnswer( 60 + (_) async => ConvoListResult( 61 + convos: [ 62 + const ConvoView( 63 + id: 'c1', 64 + rev: 'rev-1', 65 + members: [ 66 + chat_actor.ProfileViewBasic(did: 'did:plc:me', handle: 'me.bsky.social'), 67 + chat_actor.ProfileViewBasic(did: 'did:plc:other', handle: 'other.bsky.social'), 68 + ], 69 + muted: false, 70 + unreadCount: 2, 71 + ), 72 + const ConvoView( 73 + id: 'c2', 74 + rev: 'rev-2', 75 + members: [ 76 + chat_actor.ProfileViewBasic(did: 'did:plc:me', handle: 'me.bsky.social'), 77 + chat_actor.ProfileViewBasic(did: 'did:plc:req', handle: 'requester.bsky.social'), 78 + ], 79 + muted: false, 80 + unreadCount: 0, 81 + status: ConvoViewStatus.knownValue(data: KnownConvoViewStatus.request), 82 + ), 83 + ], 84 + cursor: null, 85 + ), 86 + ); 87 + when(() => convoRepository.muteConvo(any())).thenAnswer( 88 + (_) async => const ConvoView( 89 + id: 'c1', 90 + rev: 'rev-1', 91 + members: [ 92 + chat_actor.ProfileViewBasic(did: 'did:plc:me', handle: 'me.bsky.social'), 93 + chat_actor.ProfileViewBasic(did: 'did:plc:other', handle: 'other.bsky.social'), 94 + ], 95 + muted: true, 96 + unreadCount: 2, 97 + ), 98 + ); 99 + when(() => convoRepository.unmuteConvo(any())).thenAnswer( 100 + (_) async => const ConvoView( 101 + id: 'c1', 102 + rev: 'rev-1', 103 + members: [ 104 + chat_actor.ProfileViewBasic(did: 'did:plc:me', handle: 'me.bsky.social'), 105 + chat_actor.ProfileViewBasic(did: 'did:plc:other', handle: 'other.bsky.social'), 106 + ], 107 + muted: false, 108 + unreadCount: 2, 109 + ), 110 + ); 111 + }); 112 + 113 + Widget buildSubject(String initialLocation) { 114 + final router = GoRouter( 115 + initialLocation: initialLocation, 116 + routes: [ 117 + GoRoute( 118 + path: '/alerts', 119 + builder: (context, state) => MultiBlocProvider( 120 + providers: [ 121 + BlocProvider(create: (_) => NotificationBloc(notificationRepository: notificationRepository)), 122 + BlocProvider(create: (_) => UnreadCountCubit(notificationRepository: notificationRepository)), 123 + BlocProvider(create: (_) => ConvoListBloc(convoRepository: convoRepository)), 124 + RepositoryProvider<String>.value(value: 'did:plc:me'), 125 + ], 126 + child: const AlertsScreen(), 127 + ), 128 + routes: [ 129 + GoRoute( 130 + path: 'messages', 131 + builder: (context, state) => MultiBlocProvider( 132 + providers: [ 133 + BlocProvider(create: (_) => NotificationBloc(notificationRepository: notificationRepository)), 134 + BlocProvider(create: (_) => UnreadCountCubit(notificationRepository: notificationRepository)), 135 + BlocProvider(create: (_) => ConvoListBloc(convoRepository: convoRepository)), 136 + RepositoryProvider<String>.value(value: 'did:plc:me'), 137 + ], 138 + child: const AlertsScreen(initialTab: AlertsTab.messages), 139 + ), 140 + ), 141 + GoRoute( 142 + path: 'requests', 143 + builder: (context, state) => MultiBlocProvider( 144 + providers: [ 145 + BlocProvider(create: (_) => NotificationBloc(notificationRepository: notificationRepository)), 146 + BlocProvider(create: (_) => UnreadCountCubit(notificationRepository: notificationRepository)), 147 + BlocProvider(create: (_) => ConvoListBloc(convoRepository: convoRepository)), 148 + RepositoryProvider<String>.value(value: 'did:plc:me'), 149 + ], 150 + child: const AlertsScreen(initialTab: AlertsTab.requests), 151 + ), 152 + ), 153 + ], 154 + ), 155 + ], 156 + ); 157 + 158 + return MaterialApp.router(routerConfig: router); 159 + } 160 + 161 + testWidgets('shows notifications, messages, and requests tabs', (tester) async { 162 + await tester.pumpWidget(buildSubject('/alerts')); 163 + await tester.pumpAndSettle(); 164 + 165 + expect(find.text('Notifications'), findsOneWidget); 166 + expect(find.text('Messages'), findsOneWidget); 167 + expect(find.text('Requests'), findsOneWidget); 168 + expect(find.text('Mark All Read'), findsOneWidget); 169 + }); 170 + 171 + testWidgets('opens messages tab from deep link', (tester) async { 172 + await tester.pumpWidget(buildSubject('/alerts/messages')); 173 + await tester.pumpAndSettle(); 174 + 175 + expect(find.text('other.bsky.social'), findsOneWidget); 176 + expect(find.text('Mark All Read'), findsNothing); 177 + }); 178 + 179 + testWidgets('opens requests tab from deep link', (tester) async { 180 + await tester.pumpWidget(buildSubject('/alerts/requests')); 181 + await tester.pumpAndSettle(); 182 + 183 + expect(find.text('requester.bsky.social'), findsOneWidget); 184 + expect(find.text('No message requests'), findsNothing); 185 + }); 186 + }
+39
test/features/notifications/presentation/notifications_screen_test.dart
··· 8 8 import 'package:lazurite/features/notifications/cubit/unread_count_cubit.dart'; 9 9 import 'package:lazurite/features/notifications/data/notification_repository.dart'; 10 10 import 'package:lazurite/features/notifications/presentation/notifications_screen.dart'; 11 + import 'package:lazurite/features/notifications/presentation/widgets/grouped_notification_list_item.dart'; 12 + import 'package:lazurite/features/notifications/presentation/widgets/notification_list_item.dart'; 11 13 import 'package:mocktail/mocktail.dart'; 12 14 13 15 class MockNotificationRepository extends Mock implements NotificationRepository {} ··· 174 176 175 177 expect(find.text('Today'), findsOneWidget); 176 178 expect(find.text('Yesterday'), findsOneWidget); 179 + }); 180 + 181 + testWidgets('groups repeated likes on the same post into one row', (tester) async { 182 + final postUri = AtUri.parse('at://did:plc:owner/app.bsky.feed.post/post1'); 183 + final firstLike = bsky.Notification( 184 + uri: AtUri.parse('at://did:plc:alice/app.bsky.feed.like/1'), 185 + cid: 'cid-1', 186 + author: const ProfileView(did: 'did:plc:alice', handle: 'alice.bsky.social'), 187 + reason: const bsky.NotificationReason.knownValue(data: bsky.KnownNotificationReason.like), 188 + reasonSubject: postUri, 189 + record: {'text': 'Shared post'}, 190 + isRead: true, 191 + indexedAt: DateTime.now(), 192 + ); 193 + final secondLike = bsky.Notification( 194 + uri: AtUri.parse('at://did:plc:bob/app.bsky.feed.like/2'), 195 + cid: 'cid-2', 196 + author: const ProfileView(did: 'did:plc:bob', handle: 'bob.bsky.social'), 197 + reason: const bsky.NotificationReason.knownValue(data: bsky.KnownNotificationReason.like), 198 + reasonSubject: postUri, 199 + record: {'text': 'Shared post'}, 200 + isRead: true, 201 + indexedAt: DateTime.now().subtract(const Duration(minutes: 1)), 202 + ); 203 + 204 + when( 205 + () => mockNotificationRepository.listNotifications( 206 + cursor: any(named: 'cursor'), 207 + limit: any(named: 'limit'), 208 + ), 209 + ).thenAnswer((_) async => NotificationListResult(notifications: [firstLike, secondLike], cursor: null)); 210 + 211 + await tester.pumpWidget(buildSubject()); 212 + await tester.pumpAndSettle(); 213 + 214 + expect(find.byType(GroupedNotificationListItem), findsOneWidget); 215 + expect(find.byType(NotificationListItem), findsNothing); 177 216 }); 178 217 179 218 testWidgets('displays day header for older notifications', (tester) async {