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: notifications

+1547 -13
+42 -1
lib/core/router/app_router.dart
··· 5 5 import 'package:bluesky/bluesky.dart'; 6 6 import 'package:flutter_bloc/flutter_bloc.dart'; 7 7 import 'package:lazurite/core/database/app_database.dart'; 8 + import 'package:lazurite/core/logging/app_logger.dart'; 8 9 import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 9 10 import 'package:lazurite/core/router/app_shell.dart'; 10 11 import 'package:lazurite/features/auth/presentation/login_screen.dart'; ··· 15 16 import 'package:lazurite/features/feed/presentation/feed_management_screen.dart'; 16 17 import 'package:lazurite/features/feed/presentation/home_feed_screen.dart'; 17 18 import 'package:lazurite/features/logs/presentation/logs_screen.dart'; 19 + import 'package:lazurite/features/notifications/bloc/notification_bloc.dart'; 20 + import 'package:lazurite/features/notifications/cubit/unread_count_cubit.dart'; 21 + import 'package:lazurite/features/notifications/data/notification_repository.dart'; 22 + import 'package:lazurite/features/notifications/presentation/notifications_screen.dart'; 18 23 import 'package:lazurite/features/profile/presentation/profile_screen.dart'; 19 24 import 'package:lazurite/features/search/presentation/search_screen.dart'; 20 25 import 'package:lazurite/features/settings/presentation/about_screen.dart'; ··· 27 32 final GlobalKey<NavigatorState> _rootNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'root'); 28 33 final GlobalKey<NavigatorState> _homeNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'home'); 29 34 final GlobalKey<NavigatorState> _searchNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'search'); 35 + final GlobalKey<NavigatorState> _notificationsNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'notifications'); 30 36 final GlobalKey<NavigatorState> _profileNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'profile'); 31 37 final GlobalKey<NavigatorState> _settingsNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'settings'); 32 38 ··· 73 79 }, 74 80 ), 75 81 StatefulShellRoute.indexedStack( 76 - builder: (context, state, navigationShell) => AppShell(navigationShell: navigationShell), 82 + builder: (context, state, navigationShell) { 83 + UnreadCountCubit? existingCubit; 84 + try { 85 + existingCubit = context.read<UnreadCountCubit>(); 86 + } catch (_) { 87 + log.d('UnreadCountCubit not found, creating new one'); 88 + } 89 + 90 + if (existingCubit != null) { 91 + return AppShell(navigationShell: navigationShell); 92 + } 93 + 94 + return MultiBlocProvider( 95 + providers: [ 96 + BlocProvider( 97 + create: (_) => 98 + UnreadCountCubit(notificationRepository: NotificationRepository(bluesky: context.read<Bluesky>())), 99 + ), 100 + ], 101 + child: AppShell(navigationShell: navigationShell), 102 + ); 103 + }, 77 104 branches: [ 78 105 StatefulShellBranch( 79 106 navigatorKey: _homeNavigatorKey, ··· 88 115 StatefulShellBranch( 89 116 navigatorKey: _searchNavigatorKey, 90 117 routes: [GoRoute(path: '/search', builder: (context, state) => const SearchScreen())], 118 + ), 119 + StatefulShellBranch( 120 + navigatorKey: _notificationsNavigatorKey, 121 + routes: [ 122 + GoRoute( 123 + path: '/notifications', 124 + builder: (context, state) => BlocProvider( 125 + create: (_) => NotificationBloc( 126 + notificationRepository: NotificationRepository(bluesky: context.read<Bluesky>()), 127 + ), 128 + child: const NotificationsScreen(), 129 + ), 130 + ), 131 + ], 91 132 ), 92 133 StatefulShellBranch( 93 134 navigatorKey: _profileNavigatorKey,
+32 -5
lib/core/router/app_shell.dart
··· 1 1 import 'package:flutter/material.dart'; 2 + import 'package:flutter_bloc/flutter_bloc.dart'; 2 3 import 'package:go_router/go_router.dart'; 4 + import 'package:lazurite/features/notifications/cubit/unread_count_cubit.dart'; 3 5 4 6 class AppShell extends StatelessWidget { 5 7 const AppShell({super.key, required this.navigationShell}); ··· 22 24 ); 23 25 } 24 26 25 - List<Widget> get _destinations => const [ 26 - NavigationDestination(icon: Icon(Icons.home_outlined), selectedIcon: Icon(Icons.home), label: 'Home'), 27 - NavigationDestination(icon: Icon(Icons.search_outlined), selectedIcon: Icon(Icons.search), label: 'Search'), 28 - NavigationDestination(icon: Icon(Icons.person_outline), selectedIcon: Icon(Icons.person), label: 'Profile'), 29 - NavigationDestination(icon: Icon(Icons.settings_outlined), selectedIcon: Icon(Icons.settings), label: 'Settings'), 27 + List<Widget> get _destinations => [ 28 + const NavigationDestination(icon: Icon(Icons.home_outlined), selectedIcon: Icon(Icons.home), label: 'Home'), 29 + const NavigationDestination(icon: Icon(Icons.search_outlined), selectedIcon: Icon(Icons.search), label: 'Search'), 30 + NavigationDestination( 31 + icon: BlocBuilder<UnreadCountCubit, UnreadCountState>( 32 + builder: (context, state) { 33 + return Badge( 34 + isLabelVisible: state.hasUnread, 35 + label: Text(state.count > 99 ? '99+' : state.count.toString(), style: const TextStyle(fontSize: 10)), 36 + child: const Icon(Icons.notifications_outlined), 37 + ); 38 + }, 39 + ), 40 + selectedIcon: BlocBuilder<UnreadCountCubit, UnreadCountState>( 41 + builder: (context, state) { 42 + return Badge( 43 + isLabelVisible: state.hasUnread, 44 + label: Text(state.count > 99 ? '99+' : state.count.toString(), style: const TextStyle(fontSize: 10)), 45 + child: const Icon(Icons.notifications), 46 + ); 47 + }, 48 + ), 49 + label: 'Notifications', 50 + ), 51 + const NavigationDestination(icon: Icon(Icons.person_outline), selectedIcon: Icon(Icons.person), label: 'Profile'), 52 + const NavigationDestination( 53 + icon: Icon(Icons.settings_outlined), 54 + selectedIcon: Icon(Icons.settings), 55 + label: 'Settings', 56 + ), 30 57 ]; 31 58 }
+93
lib/features/notifications/bloc/notification_bloc.dart
··· 1 + import 'package:bluesky/app_bsky_notification_listnotifications.dart'; 2 + import 'package:equatable/equatable.dart'; 3 + import 'package:flutter_bloc/flutter_bloc.dart'; 4 + import 'package:lazurite/core/logging/app_logger.dart'; 5 + import 'package:lazurite/features/notifications/data/notification_repository.dart'; 6 + 7 + part 'notification_event.dart'; 8 + part 'notification_state.dart'; 9 + 10 + class NotificationBloc extends Bloc<NotificationEvent, NotificationState> { 11 + NotificationBloc({required NotificationRepository notificationRepository}) 12 + : _notificationRepository = notificationRepository, 13 + super(const NotificationState.initial()) { 14 + on<NotificationsRequested>(_onNotificationsRequested); 15 + on<NotificationsRefreshed>(_onNotificationsRefreshed); 16 + on<NotificationsPageLoaded>(_onNotificationsPageLoaded); 17 + on<NotificationsMarkedRead>(_onNotificationsMarkedRead); 18 + } 19 + 20 + final NotificationRepository _notificationRepository; 21 + 22 + Future<void> _onNotificationsRequested(NotificationsRequested event, Emitter<NotificationState> emit) async { 23 + emit(const NotificationState.loading()); 24 + 25 + try { 26 + final result = await _notificationRepository.listNotifications(limit: event.limit); 27 + 28 + emit( 29 + NotificationState.loaded( 30 + notifications: result.notifications, 31 + cursor: result.cursor, 32 + hasMore: result.cursor != null, 33 + ), 34 + ); 35 + } catch (error) { 36 + emit(NotificationState.error('Failed to load notifications: $error')); 37 + } 38 + } 39 + 40 + Future<void> _onNotificationsRefreshed(NotificationsRefreshed event, Emitter<NotificationState> emit) async { 41 + if (state.status != NotificationStatus.loaded) { 42 + return; 43 + } 44 + 45 + emit(state.copyWith(isRefreshing: true)); 46 + 47 + try { 48 + final result = await _notificationRepository.listNotifications(limit: 50); 49 + 50 + emit( 51 + state.copyWith( 52 + notifications: result.notifications, 53 + cursor: result.cursor, 54 + hasMore: result.cursor != null, 55 + isRefreshing: false, 56 + ), 57 + ); 58 + } catch (error) { 59 + emit(state.copyWith(isRefreshing: false)); 60 + } 61 + } 62 + 63 + Future<void> _onNotificationsPageLoaded(NotificationsPageLoaded event, Emitter<NotificationState> emit) async { 64 + if (state.status != NotificationStatus.loaded || state.cursor == null || state.isLoadingMore) { 65 + return; 66 + } 67 + 68 + emit(state.copyWith(isLoadingMore: true)); 69 + 70 + try { 71 + final result = await _notificationRepository.listNotifications(cursor: state.cursor, limit: event.limit); 72 + 73 + emit( 74 + state.copyWith( 75 + notifications: [...state.notifications, ...result.notifications], 76 + cursor: result.cursor, 77 + hasMore: result.cursor != null, 78 + isLoadingMore: false, 79 + ), 80 + ); 81 + } catch (error) { 82 + emit(state.copyWith(isLoadingMore: false, hasMore: false)); 83 + } 84 + } 85 + 86 + Future<void> _onNotificationsMarkedRead(NotificationsMarkedRead event, Emitter<NotificationState> emit) async { 87 + try { 88 + await _notificationRepository.updateSeen(); 89 + } catch (_) { 90 + log.w('Failed to mark notifications as read/seen'); 91 + } 92 + } 93 + }
+34
lib/features/notifications/bloc/notification_event.dart
··· 1 + part of 'notification_bloc.dart'; 2 + 3 + sealed class NotificationEvent extends Equatable { 4 + const NotificationEvent(); 5 + 6 + @override 7 + List<Object?> get props => []; 8 + } 9 + 10 + class NotificationsRequested extends NotificationEvent { 11 + const NotificationsRequested({this.limit = 50}); 12 + 13 + final int limit; 14 + 15 + @override 16 + List<Object?> get props => [limit]; 17 + } 18 + 19 + class NotificationsRefreshed extends NotificationEvent { 20 + const NotificationsRefreshed(); 21 + } 22 + 23 + class NotificationsPageLoaded extends NotificationEvent { 24 + const NotificationsPageLoaded({this.limit = 50}); 25 + 26 + final int limit; 27 + 28 + @override 29 + List<Object?> get props => [limit]; 30 + } 31 + 32 + class NotificationsMarkedRead extends NotificationEvent { 33 + const NotificationsMarkedRead(); 34 + }
+55
lib/features/notifications/bloc/notification_state.dart
··· 1 + part of 'notification_bloc.dart'; 2 + 3 + enum NotificationStatus { initial, loading, loaded, error } 4 + 5 + class NotificationState extends Equatable { 6 + const NotificationState._({ 7 + required this.status, 8 + this.notifications = const [], 9 + this.cursor, 10 + this.hasMore = false, 11 + this.isLoadingMore = false, 12 + this.isRefreshing = false, 13 + this.errorMessage, 14 + }); 15 + 16 + const NotificationState.initial() : this._(status: NotificationStatus.initial); 17 + 18 + const NotificationState.loading() : this._(status: NotificationStatus.loading); 19 + 20 + const NotificationState.loaded({required List<Notification> notifications, String? cursor, bool hasMore = false}) 21 + : this._(status: NotificationStatus.loaded, notifications: notifications, cursor: cursor, hasMore: hasMore); 22 + 23 + const NotificationState.error(String message) : this._(status: NotificationStatus.error, errorMessage: message); 24 + 25 + final NotificationStatus status; 26 + final List<Notification> notifications; 27 + final String? cursor; 28 + final bool hasMore; 29 + final bool isLoadingMore; 30 + final bool isRefreshing; 31 + final String? errorMessage; 32 + 33 + NotificationState copyWith({ 34 + NotificationStatus? status, 35 + List<Notification>? notifications, 36 + String? cursor, 37 + bool? hasMore, 38 + bool? isLoadingMore, 39 + bool? isRefreshing, 40 + String? errorMessage, 41 + }) { 42 + return NotificationState._( 43 + status: status ?? this.status, 44 + notifications: notifications ?? this.notifications, 45 + cursor: cursor ?? this.cursor, 46 + hasMore: hasMore ?? this.hasMore, 47 + isLoadingMore: isLoadingMore ?? this.isLoadingMore, 48 + isRefreshing: isRefreshing ?? this.isRefreshing, 49 + errorMessage: errorMessage ?? this.errorMessage, 50 + ); 51 + } 52 + 53 + @override 54 + List<Object?> get props => [status, notifications, cursor, hasMore, isLoadingMore, isRefreshing, errorMessage]; 55 + }
+54
lib/features/notifications/cubit/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/features/notifications/data/notification_repository.dart'; 6 + import 'package:lazurite/core/logging/app_logger.dart'; 7 + 8 + class UnreadCountCubit extends Cubit<UnreadCountState> { 9 + UnreadCountCubit({required NotificationRepository notificationRepository}) 10 + : _notificationRepository = notificationRepository, 11 + super(const UnreadCountState(0)) { 12 + _startPolling(); 13 + } 14 + 15 + final NotificationRepository _notificationRepository; 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 _notificationRepository.getUnreadCount(); 28 + emit(UnreadCountState(count)); 29 + } catch (_) { 30 + log.w('Failed to poll 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 UnreadCountState extends Equatable { 46 + const UnreadCountState(this.count); 47 + 48 + final int count; 49 + 50 + bool get hasUnread => count > 0; 51 + 52 + @override 53 + List<Object?> get props => [count]; 54 + }
+35
lib/features/notifications/data/notification_repository.dart
··· 1 + import 'package:bluesky/app_bsky_notification_listnotifications.dart'; 2 + import 'package:bluesky/bluesky.dart'; 3 + 4 + class NotificationRepository { 5 + NotificationRepository({required Bluesky bluesky}) : _bluesky = bluesky; 6 + 7 + final Bluesky _bluesky; 8 + 9 + Future<NotificationListResult> listNotifications({String? cursor, int limit = 50}) async { 10 + final response = await _bluesky.notification.listNotifications(cursor: cursor, limit: limit); 11 + 12 + return NotificationListResult( 13 + notifications: response.data.notifications, 14 + cursor: response.data.cursor, 15 + seenAt: response.data.seenAt, 16 + ); 17 + } 18 + 19 + Future<int> getUnreadCount() async { 20 + final response = await _bluesky.notification.getUnreadCount(); 21 + return response.data.count; 22 + } 23 + 24 + Future<void> updateSeen() async { 25 + await _bluesky.notification.updateSeen(seenAt: DateTime.now()); 26 + } 27 + } 28 + 29 + class NotificationListResult { 30 + NotificationListResult({required this.notifications, this.cursor, this.seenAt}); 31 + 32 + final List<Notification> notifications; 33 + final String? cursor; 34 + final DateTime? seenAt; 35 + }
+219
lib/features/notifications/presentation/notifications_screen.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/notification_list_item.dart'; 7 + 8 + class NotificationsScreen extends StatefulWidget { 9 + const NotificationsScreen({super.key}); 10 + 11 + @override 12 + State<NotificationsScreen> createState() => _NotificationsScreenState(); 13 + } 14 + 15 + class _NotificationsScreenState extends State<NotificationsScreen> { 16 + final ScrollController _scrollController = ScrollController(); 17 + 18 + @override 19 + void initState() { 20 + super.initState(); 21 + _scrollController.addListener(_onScroll); 22 + context.read<NotificationBloc>().add(const NotificationsRequested()); 23 + context.read<NotificationBloc>().add(const NotificationsMarkedRead()); 24 + context.read<UnreadCountCubit>().refresh(); 25 + } 26 + 27 + @override 28 + void dispose() { 29 + _scrollController.removeListener(_onScroll); 30 + _scrollController.dispose(); 31 + super.dispose(); 32 + } 33 + 34 + void _onScroll() { 35 + if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) { 36 + context.read<NotificationBloc>().add(const NotificationsPageLoaded()); 37 + } 38 + } 39 + 40 + Future<void> _onRefresh() async { 41 + context.read<NotificationBloc>().add(const NotificationsRefreshed()); 42 + context.read<NotificationBloc>().add(const NotificationsMarkedRead()); 43 + await context.read<UnreadCountCubit>().refresh(); 44 + } 45 + 46 + void _markAllRead() { 47 + context.read<NotificationBloc>().add(const NotificationsMarkedRead()); 48 + context.read<UnreadCountCubit>().refresh(); 49 + } 50 + 51 + @override 52 + Widget build(BuildContext context) { 53 + return Scaffold( 54 + appBar: AppBar( 55 + title: const Text('Notifications'), 56 + actions: [TextButton(onPressed: _markAllRead, child: const Text('Mark All Read'))], 57 + ), 58 + body: BlocBuilder<NotificationBloc, NotificationState>( 59 + builder: (context, state) { 60 + if (state.status == NotificationStatus.initial || 61 + (state.status == NotificationStatus.loading && state.notifications.isEmpty)) { 62 + return const Center(child: CircularProgressIndicator()); 63 + } 64 + 65 + if (state.status == NotificationStatus.error && state.notifications.isEmpty) { 66 + return Center( 67 + child: Column( 68 + mainAxisAlignment: MainAxisAlignment.center, 69 + children: [ 70 + Text('Failed to load notifications', style: Theme.of(context).textTheme.titleMedium), 71 + const SizedBox(height: 8), 72 + Text(state.errorMessage ?? 'Unknown error', textAlign: TextAlign.center), 73 + const SizedBox(height: 16), 74 + FilledButton( 75 + onPressed: () => context.read<NotificationBloc>().add(const NotificationsRequested()), 76 + child: const Text('Retry'), 77 + ), 78 + ], 79 + ), 80 + ); 81 + } 82 + 83 + if (state.notifications.isEmpty) { 84 + return Center(child: Text('No notifications yet', style: Theme.of(context).textTheme.bodyLarge)); 85 + } 86 + 87 + final groupedNotifications = _groupNotificationsByDay(state.notifications); 88 + 89 + return RefreshIndicator( 90 + onRefresh: _onRefresh, 91 + child: ListView.builder( 92 + controller: _scrollController, 93 + itemCount: _calculateItemCount(groupedNotifications, state), 94 + itemBuilder: (context, index) { 95 + final item = _getItemAtIndex(groupedNotifications, index); 96 + 97 + if (item is String) { 98 + return _DayHeader(title: item); 99 + } else if (item is bsky.Notification) { 100 + return NotificationListItem(notification: item); 101 + } else if (item == null) { 102 + return const Center( 103 + child: Padding(padding: EdgeInsets.all(16), child: CircularProgressIndicator()), 104 + ); 105 + } 106 + 107 + return const SizedBox.shrink(); 108 + }, 109 + ), 110 + ); 111 + }, 112 + ), 113 + ); 114 + } 115 + 116 + Map<DateTime, List<bsky.Notification>> _groupNotificationsByDay(List<bsky.Notification> notifications) { 117 + final grouped = <DateTime, List<bsky.Notification>>{}; 118 + 119 + for (final notification in notifications) { 120 + final date = DateTime(notification.indexedAt.year, notification.indexedAt.month, notification.indexedAt.day); 121 + 122 + grouped.putIfAbsent(date, () => []).add(notification); 123 + } 124 + 125 + return Map.fromEntries(grouped.entries.toList()..sort((a, b) => b.key.compareTo(a.key))); 126 + } 127 + 128 + int _calculateItemCount(Map<DateTime, List<bsky.Notification>> grouped, NotificationState state) { 129 + int count = 0; 130 + for (final entry in grouped.entries) { 131 + count++; 132 + count += entry.value.length; 133 + } 134 + if (state.isLoadingMore) { 135 + count++; 136 + } 137 + return count; 138 + } 139 + 140 + dynamic _getItemAtIndex(Map<DateTime, List<bsky.Notification>> grouped, int index) { 141 + int currentIndex = 0; 142 + 143 + for (final entry in grouped.entries) { 144 + if (currentIndex == index) { 145 + return _formatDayHeader(entry.key); 146 + } 147 + currentIndex++; 148 + 149 + for (final notification in entry.value) { 150 + if (currentIndex == index) { 151 + return notification; 152 + } 153 + currentIndex++; 154 + } 155 + } 156 + 157 + return null; 158 + } 159 + 160 + String _formatDayHeader(DateTime date) { 161 + final now = DateTime.now(); 162 + final today = DateTime(now.year, now.month, now.day); 163 + final yesterday = today.subtract(const Duration(days: 1)); 164 + 165 + if (date == today) { 166 + return 'Today'; 167 + } else if (date == yesterday) { 168 + return 'Yesterday'; 169 + } else { 170 + return _formatDate(date); 171 + } 172 + } 173 + 174 + String _formatDate(DateTime date) { 175 + final months = [ 176 + 'January', 177 + 'February', 178 + 'March', 179 + 'April', 180 + 'May', 181 + 'June', 182 + 'July', 183 + 'August', 184 + 'September', 185 + 'October', 186 + 'November', 187 + 'December', 188 + ]; 189 + 190 + return '${months[date.month - 1]} ${date.day}'; 191 + } 192 + } 193 + 194 + class _DayHeader extends StatelessWidget { 195 + const _DayHeader({required this.title}); 196 + 197 + final String title; 198 + 199 + @override 200 + Widget build(BuildContext context) { 201 + final theme = Theme.of(context); 202 + 203 + return Container( 204 + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), 205 + decoration: BoxDecoration( 206 + color: theme.colorScheme.surface, 207 + border: Border(bottom: BorderSide(color: theme.dividerColor)), 208 + ), 209 + child: Text( 210 + title, 211 + style: theme.textTheme.labelLarge?.copyWith( 212 + fontWeight: FontWeight.w600, 213 + color: theme.colorScheme.onSurfaceVariant, 214 + letterSpacing: 0.5, 215 + ), 216 + ), 217 + ); 218 + } 219 + }
+266
lib/features/notifications/presentation/widgets/notification_list_item.dart
··· 1 + import 'package:bluesky/app_bsky_notification_listnotifications.dart' as bsky; 2 + import 'package:flutter/material.dart' hide Notification; 3 + import 'package:go_router/go_router.dart'; 4 + import 'package:intl/intl.dart'; 5 + 6 + class NotificationListItem extends StatelessWidget { 7 + const NotificationListItem({super.key, required this.notification}); 8 + 9 + final bsky.Notification notification; 10 + 11 + @override 12 + Widget build(BuildContext context) { 13 + final theme = Theme.of(context); 14 + final isUnread = !notification.isRead; 15 + 16 + return InkWell( 17 + onTap: () => _onTap(context), 18 + child: Container( 19 + decoration: BoxDecoration( 20 + border: Border(left: isUnread ? BorderSide(color: theme.colorScheme.primary, width: 3) : BorderSide.none), 21 + color: isUnread ? theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5) : null, 22 + ), 23 + child: Padding( 24 + padding: const EdgeInsets.all(14), 25 + child: Row( 26 + crossAxisAlignment: CrossAxisAlignment.start, 27 + children: [ 28 + _buildReasonIcon(theme), 29 + const SizedBox(width: 12), 30 + Expanded( 31 + child: Column( 32 + crossAxisAlignment: CrossAxisAlignment.start, 33 + children: [ 34 + _buildActorRow(), 35 + const SizedBox(height: 4), 36 + _buildSummary(theme), 37 + const SizedBox(height: 2), 38 + _buildTime(theme), 39 + if (_shouldShowPreview) ...[const SizedBox(height: 8), _buildPreview(theme)], 40 + ], 41 + ), 42 + ), 43 + ], 44 + ), 45 + ), 46 + ), 47 + ); 48 + } 49 + 50 + Widget _buildReasonIcon(ThemeData theme) { 51 + final reason = notification.reason; 52 + final colorScheme = theme.colorScheme; 53 + 54 + Color backgroundColor; 55 + Color iconColor; 56 + IconData iconData; 57 + 58 + if (reason.isKnownValue) { 59 + switch (reason.knownValue) { 60 + case bsky.KnownNotificationReason.like: 61 + backgroundColor = colorScheme.error.withValues(alpha: 0.1); 62 + iconColor = colorScheme.error; 63 + iconData = Icons.favorite; 64 + case bsky.KnownNotificationReason.repost: 65 + backgroundColor = Colors.green.withValues(alpha: 0.1); 66 + iconColor = Colors.green; 67 + iconData = Icons.repeat; 68 + case bsky.KnownNotificationReason.follow: 69 + backgroundColor = colorScheme.primary.withValues(alpha: 0.1); 70 + iconColor = colorScheme.primary; 71 + iconData = Icons.person_add; 72 + case bsky.KnownNotificationReason.reply: 73 + backgroundColor = colorScheme.secondary.withValues(alpha: 0.1); 74 + iconColor = colorScheme.secondary; 75 + iconData = Icons.chat_bubble; 76 + case bsky.KnownNotificationReason.mention: 77 + backgroundColor = colorScheme.primary.withValues(alpha: 0.1); 78 + iconColor = colorScheme.primary; 79 + iconData = Icons.alternate_email; 80 + case bsky.KnownNotificationReason.quote: 81 + backgroundColor = Colors.purple.withValues(alpha: 0.1); 82 + iconColor = Colors.purple; 83 + iconData = Icons.format_quote; 84 + default: 85 + backgroundColor = colorScheme.surfaceContainerHighest; 86 + iconColor = colorScheme.onSurfaceVariant; 87 + iconData = Icons.notifications; 88 + } 89 + } else { 90 + backgroundColor = colorScheme.surfaceContainerHighest; 91 + iconColor = colorScheme.onSurfaceVariant; 92 + iconData = Icons.notifications; 93 + } 94 + 95 + return Container( 96 + width: 32, 97 + height: 32, 98 + decoration: BoxDecoration(color: backgroundColor, shape: BoxShape.circle), 99 + child: Icon(iconData, size: 16, color: iconColor), 100 + ); 101 + } 102 + 103 + Widget _buildActorRow() { 104 + final author = notification.author; 105 + final avatarUrl = author.avatar; 106 + 107 + return Row( 108 + children: [ 109 + Container( 110 + width: 28, 111 + height: 28, 112 + decoration: BoxDecoration(color: Colors.grey.shade300, shape: BoxShape.circle), 113 + child: avatarUrl != null 114 + ? ClipOval( 115 + child: Image.network( 116 + avatarUrl, 117 + width: 28, 118 + height: 28, 119 + fit: BoxFit.cover, 120 + errorBuilder: (_, _, _) => _buildAvatarPlaceholder(), 121 + ), 122 + ) 123 + : _buildAvatarPlaceholder(), 124 + ), 125 + ], 126 + ); 127 + } 128 + 129 + Widget _buildAvatarPlaceholder() { 130 + final author = notification.author; 131 + final displayName = author.displayName; 132 + final handle = author.handle; 133 + final initials = _getInitials(displayName ?? handle); 134 + 135 + return Center( 136 + child: Text( 137 + initials, 138 + style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: Colors.black54), 139 + ), 140 + ); 141 + } 142 + 143 + String _getInitials(String text) { 144 + final parts = text.split(' '); 145 + if (parts.length >= 2) { 146 + return '${parts[0][0]}${parts[1][0]}'.toUpperCase(); 147 + } 148 + return text.substring(0, text.length >= 2 ? 2 : 1).toUpperCase(); 149 + } 150 + 151 + Widget _buildSummary(ThemeData theme) { 152 + final author = notification.author; 153 + final displayName = author.displayName ?? author.handle; 154 + final reasonText = _getReasonText(); 155 + 156 + return RichText( 157 + text: TextSpan( 158 + children: [ 159 + TextSpan( 160 + text: displayName, 161 + style: theme.textTheme.bodyMedium?.copyWith( 162 + fontWeight: FontWeight.w600, 163 + color: theme.colorScheme.onSurface, 164 + ), 165 + ), 166 + TextSpan( 167 + text: ' $reasonText', 168 + style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant), 169 + ), 170 + ], 171 + ), 172 + ); 173 + } 174 + 175 + String _getReasonText() { 176 + final reason = notification.reason; 177 + 178 + if (reason.isKnownValue) { 179 + switch (reason.knownValue) { 180 + case bsky.KnownNotificationReason.like: 181 + return 'liked your post'; 182 + case bsky.KnownNotificationReason.repost: 183 + return 'reposted your post'; 184 + case bsky.KnownNotificationReason.follow: 185 + return 'followed you'; 186 + case bsky.KnownNotificationReason.mention: 187 + return 'mentioned you'; 188 + case bsky.KnownNotificationReason.reply: 189 + return 'replied to your post'; 190 + case bsky.KnownNotificationReason.quote: 191 + return 'quoted your post'; 192 + default: 193 + return 'interacted with you'; 194 + } 195 + } 196 + 197 + return 'interacted with you'; 198 + } 199 + 200 + Widget _buildTime(ThemeData theme) { 201 + return Text( 202 + _formatTime(notification.indexedAt), 203 + style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), 204 + ); 205 + } 206 + 207 + String _formatTime(DateTime time) { 208 + final now = DateTime.now(); 209 + final difference = now.difference(time); 210 + 211 + if (difference.inMinutes < 1) { 212 + return 'Just now'; 213 + } else if (difference.inMinutes < 60) { 214 + return '${difference.inMinutes}m ago'; 215 + } else if (difference.inHours < 24) { 216 + return '${difference.inHours}h ago'; 217 + } else if (difference.inDays < 7) { 218 + return '${difference.inDays}d ago'; 219 + } else { 220 + return DateFormat('MMM d').format(time); 221 + } 222 + } 223 + 224 + bool get _shouldShowPreview { 225 + final reason = notification.reason; 226 + if (reason.isKnownValue) { 227 + return reason.knownValue != bsky.KnownNotificationReason.follow; 228 + } 229 + return false; 230 + } 231 + 232 + Widget _buildPreview(ThemeData theme) { 233 + final record = notification.record; 234 + final text = record['text'] as String?; 235 + 236 + if (text == null || text.isEmpty) { 237 + return const SizedBox.shrink(); 238 + } 239 + 240 + return Container( 241 + padding: const EdgeInsets.all(10), 242 + decoration: BoxDecoration( 243 + color: theme.colorScheme.surfaceContainerHighest, 244 + borderRadius: BorderRadius.circular(8), 245 + border: Border.all(color: theme.dividerColor), 246 + ), 247 + child: Text( 248 + text, 249 + maxLines: 2, 250 + overflow: TextOverflow.ellipsis, 251 + style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), 252 + ), 253 + ); 254 + } 255 + 256 + void _onTap(BuildContext context) { 257 + final reason = notification.reason; 258 + 259 + if (reason.isKnownValue && reason.knownValue == bsky.KnownNotificationReason.follow) { 260 + context.push('/profile/view?actor=${notification.author.did}'); 261 + } else { 262 + final uri = notification.uri; 263 + context.push('/post?uri=${Uri.encodeComponent(uri.toString())}'); 264 + } 265 + } 266 + }
+18 -1
test/core/router/app_router_test.dart
··· 9 9 import 'package:lazurite/features/auth/data/models/auth_models.dart'; 10 10 import 'package:lazurite/features/feed/bloc/feed_bloc.dart'; 11 11 import 'package:lazurite/features/feed/cubit/feed_preferences_cubit.dart'; 12 + import 'package:lazurite/features/notifications/cubit/unread_count_cubit.dart'; 13 + import 'package:lazurite/features/notifications/data/notification_repository.dart'; 12 14 import 'package:lazurite/features/profile/bloc/profile_bloc.dart'; 13 15 import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 14 16 import 'package:lazurite/features/settings/bloc/settings_state.dart'; ··· 24 26 25 27 class MockSettingsCubit extends MockCubit<SettingsState> implements SettingsCubit {} 26 28 29 + class MockUnreadCountCubit extends MockCubit<UnreadCountState> implements UnreadCountCubit {} 30 + 31 + class MockNotificationRepository extends Mock implements NotificationRepository {} 32 + 27 33 void main() { 28 34 late MockAuthBloc authBloc; 29 35 late MockFeedPreferencesCubit feedPreferencesCubit; 30 36 late MockProfileBloc profileBloc; 31 37 late MockFeedBloc feedBloc; 32 38 late MockSettingsCubit settingsCubit; 39 + late MockUnreadCountCubit unreadCountCubit; 40 + late MockNotificationRepository notificationRepository; 33 41 34 42 const tokens = AuthTokens( 35 43 accessToken: 'access', ··· 55 63 profileBloc = MockProfileBloc(); 56 64 feedBloc = MockFeedBloc(); 57 65 settingsCubit = MockSettingsCubit(); 66 + unreadCountCubit = MockUnreadCountCubit(); 67 + notificationRepository = MockNotificationRepository(); 58 68 59 69 when(() => authBloc.state).thenReturn(const AuthState.authenticated(tokens)); 60 70 when(() => feedPreferencesCubit.state).thenReturn(const FeedPreferencesState.loaded(feeds: [])); ··· 69 79 useSystemTheme: false, 70 80 ), 71 81 ); 82 + when(() => unreadCountCubit.state).thenReturn(const UnreadCountState(0)); 83 + when(() => notificationRepository.getUnreadCount()).thenAnswer((_) async => 0); 72 84 73 85 whenListen(authBloc, const Stream<AuthState>.empty(), initialState: const AuthState.authenticated(tokens)); 74 86 whenListen( ··· 96 108 useSystemTheme: false, 97 109 ), 98 110 ); 111 + whenListen(unreadCountCubit, const Stream<UnreadCountState>.empty(), initialState: const UnreadCountState(0)); 99 112 }); 100 113 101 114 Widget buildSubject() { ··· 106 119 BlocProvider<ProfileBloc>.value(value: profileBloc), 107 120 BlocProvider<FeedBloc>.value(value: feedBloc), 108 121 BlocProvider<SettingsCubit>.value(value: settingsCubit), 122 + BlocProvider<UnreadCountCubit>.value(value: unreadCountCubit), 109 123 ], 110 - child: MaterialApp.router(routerConfig: AppRouter(authBloc: authBloc).router), 124 + child: RepositoryProvider<NotificationRepository>( 125 + create: (_) => notificationRepository, 126 + child: MaterialApp.router(routerConfig: AppRouter(authBloc: authBloc).router), 127 + ), 111 128 ); 112 129 } 113 130
+204
test/features/notifications/bloc/notification_bloc_test.dart
··· 1 + import 'package:atproto_core/atproto_core.dart'; 2 + import 'package:bloc_test/bloc_test.dart'; 3 + import 'package:bluesky/app_bsky_actor_defs.dart'; 4 + import 'package:bluesky/app_bsky_notification_listnotifications.dart' as bsky; 5 + import 'package:flutter_test/flutter_test.dart'; 6 + import 'package:lazurite/features/notifications/bloc/notification_bloc.dart'; 7 + import 'package:lazurite/features/notifications/data/notification_repository.dart'; 8 + import 'package:mocktail/mocktail.dart'; 9 + 10 + class MockNotificationRepository extends Mock implements NotificationRepository {} 11 + 12 + void main() { 13 + late MockNotificationRepository mockNotificationRepository; 14 + 15 + setUp(() { 16 + mockNotificationRepository = MockNotificationRepository(); 17 + }); 18 + 19 + group('NotificationBloc', () { 20 + final sampleNotification = bsky.Notification( 21 + uri: AtUri.parse('at://did:plc:author/app.bsky.feed.post/abc'), 22 + cid: 'cid-123', 23 + author: ProfileView(did: 'did:plc:author', handle: 'author.bsky.social'), 24 + reason: const bsky.NotificationReason.knownValue(data: bsky.KnownNotificationReason.like), 25 + record: {r'$type': 'app.bsky.feed.post', 'text': 'Hello world'}, 26 + isRead: false, 27 + indexedAt: DateTime.utc(2026, 3, 15), 28 + ); 29 + 30 + blocTest<NotificationBloc, NotificationState>( 31 + 'emits loading and loaded when NotificationsRequested succeeds', 32 + build: () => NotificationBloc(notificationRepository: mockNotificationRepository), 33 + setUp: () { 34 + when( 35 + () => mockNotificationRepository.listNotifications( 36 + cursor: any(named: 'cursor'), 37 + limit: any(named: 'limit'), 38 + ), 39 + ).thenAnswer((_) async => NotificationListResult(notifications: [sampleNotification], cursor: 'cursor-1')); 40 + }, 41 + act: (bloc) => bloc.add(const NotificationsRequested()), 42 + expect: () => [ 43 + const NotificationState.loading(), 44 + predicate<NotificationState>( 45 + (state) => 46 + state.status == NotificationStatus.loaded && 47 + state.notifications.length == 1 && 48 + state.cursor == 'cursor-1' && 49 + state.hasMore == true, 50 + ), 51 + ], 52 + ); 53 + 54 + blocTest<NotificationBloc, NotificationState>( 55 + 'emits error when NotificationsRequested fails', 56 + build: () => NotificationBloc(notificationRepository: mockNotificationRepository), 57 + setUp: () { 58 + when( 59 + () => mockNotificationRepository.listNotifications( 60 + cursor: any(named: 'cursor'), 61 + limit: any(named: 'limit'), 62 + ), 63 + ).thenThrow(Exception('Network error')); 64 + }, 65 + act: (bloc) => bloc.add(const NotificationsRequested()), 66 + expect: () => [ 67 + const NotificationState.loading(), 68 + predicate<NotificationState>((state) => state.status == NotificationStatus.error), 69 + ], 70 + ); 71 + 72 + blocTest<NotificationBloc, NotificationState>( 73 + 'loads more notifications on NotificationsPageLoaded', 74 + build: () => NotificationBloc(notificationRepository: mockNotificationRepository), 75 + seed: () => NotificationState.loaded(notifications: [sampleNotification], cursor: 'cursor-1', hasMore: true), 76 + setUp: () { 77 + final secondNotification = bsky.Notification( 78 + uri: AtUri.parse('at://did:plc:author2/app.bsky.feed.post/def'), 79 + cid: 'cid-456', 80 + author: ProfileView(did: 'did:plc:author2', handle: 'author2.bsky.social'), 81 + reason: const bsky.NotificationReason.knownValue(data: bsky.KnownNotificationReason.follow), 82 + record: {}, 83 + isRead: true, 84 + indexedAt: DateTime.utc(2026, 3, 14), 85 + ); 86 + when( 87 + () => mockNotificationRepository.listNotifications( 88 + cursor: 'cursor-1', 89 + limit: any(named: 'limit'), 90 + ), 91 + ).thenAnswer((_) async => NotificationListResult(notifications: [secondNotification], cursor: null)); 92 + }, 93 + act: (bloc) => bloc.add(const NotificationsPageLoaded()), 94 + expect: () => [ 95 + predicate<NotificationState>((state) => state.isLoadingMore), 96 + predicate<NotificationState>( 97 + (state) => 98 + state.status == NotificationStatus.loaded && 99 + state.notifications.length == 2 && 100 + !state.hasMore && 101 + !state.isLoadingMore, 102 + ), 103 + ], 104 + verify: (_) { 105 + verify( 106 + () => mockNotificationRepository.listNotifications( 107 + cursor: 'cursor-1', 108 + limit: any(named: 'limit'), 109 + ), 110 + ).called(1); 111 + }, 112 + ); 113 + 114 + blocTest<NotificationBloc, NotificationState>( 115 + 'does not load more when already loading more', 116 + build: () => NotificationBloc(notificationRepository: mockNotificationRepository), 117 + seed: () => NotificationState.loaded( 118 + notifications: [sampleNotification], 119 + cursor: 'cursor-1', 120 + hasMore: true, 121 + ).copyWith(isLoadingMore: true), 122 + act: (bloc) => bloc.add(const NotificationsPageLoaded()), 123 + expect: () => [], 124 + verify: (_) { 125 + verifyNever( 126 + () => mockNotificationRepository.listNotifications( 127 + cursor: any(named: 'cursor'), 128 + limit: any(named: 'limit'), 129 + ), 130 + ); 131 + }, 132 + ); 133 + 134 + blocTest<NotificationBloc, NotificationState>( 135 + 'does not load more when no cursor', 136 + build: () => NotificationBloc(notificationRepository: mockNotificationRepository), 137 + seed: () => NotificationState.loaded(notifications: [sampleNotification], cursor: null, hasMore: false), 138 + act: (bloc) => bloc.add(const NotificationsPageLoaded()), 139 + expect: () => [], 140 + verify: (_) { 141 + verifyNever( 142 + () => mockNotificationRepository.listNotifications( 143 + cursor: any(named: 'cursor'), 144 + limit: any(named: 'limit'), 145 + ), 146 + ); 147 + }, 148 + ); 149 + 150 + blocTest<NotificationBloc, NotificationState>( 151 + 'refreshes notifications on NotificationsRefreshed', 152 + build: () => NotificationBloc(notificationRepository: mockNotificationRepository), 153 + seed: () => NotificationState.loaded(notifications: [sampleNotification], cursor: 'old-cursor', hasMore: true), 154 + setUp: () { 155 + final newNotification = bsky.Notification( 156 + uri: AtUri.parse('at://did:plc:new/app.bsky.feed.post/new'), 157 + cid: 'cid-new', 158 + author: ProfileView(did: 'did:plc:new', handle: 'new.bsky.social'), 159 + reason: const bsky.NotificationReason.knownValue(data: bsky.KnownNotificationReason.repost), 160 + record: {}, 161 + isRead: false, 162 + indexedAt: DateTime.utc(2026, 3, 16), 163 + ); 164 + when( 165 + () => mockNotificationRepository.listNotifications(limit: any(named: 'limit')), 166 + ).thenAnswer((_) async => NotificationListResult(notifications: [newNotification], cursor: 'new-cursor')); 167 + }, 168 + act: (bloc) => bloc.add(const NotificationsRefreshed()), 169 + expect: () => [ 170 + predicate<NotificationState>((state) => state.isRefreshing), 171 + predicate<NotificationState>( 172 + (state) => 173 + state.status == NotificationStatus.loaded && 174 + state.notifications.length == 1 && 175 + state.cursor == 'new-cursor' && 176 + !state.isRefreshing, 177 + ), 178 + ], 179 + ); 180 + 181 + blocTest<NotificationBloc, NotificationState>( 182 + 'calls updateSeen on NotificationsMarkedRead', 183 + build: () => NotificationBloc(notificationRepository: mockNotificationRepository), 184 + setUp: () { 185 + when(() => mockNotificationRepository.updateSeen()).thenAnswer((_) async {}); 186 + }, 187 + act: (bloc) => bloc.add(const NotificationsMarkedRead()), 188 + expect: () => [], 189 + verify: (_) { 190 + verify(() => mockNotificationRepository.updateSeen()).called(1); 191 + }, 192 + ); 193 + 194 + blocTest<NotificationBloc, NotificationState>( 195 + 'handles updateSeen failure silently', 196 + build: () => NotificationBloc(notificationRepository: mockNotificationRepository), 197 + setUp: () { 198 + when(() => mockNotificationRepository.updateSeen()).thenThrow(Exception('Network error')); 199 + }, 200 + act: (bloc) => bloc.add(const NotificationsMarkedRead()), 201 + expect: () => [], 202 + ); 203 + }); 204 + }
+97
test/features/notifications/cubit/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/notifications/cubit/unread_count_cubit.dart'; 4 + import 'package:lazurite/features/notifications/data/notification_repository.dart'; 5 + import 'package:mocktail/mocktail.dart'; 6 + 7 + class MockNotificationRepository extends Mock implements NotificationRepository {} 8 + 9 + void main() { 10 + late MockNotificationRepository mockNotificationRepository; 11 + 12 + setUp(() { 13 + mockNotificationRepository = MockNotificationRepository(); 14 + }); 15 + 16 + group('UnreadCountCubit', () { 17 + blocTest<UnreadCountCubit, UnreadCountState>( 18 + 'emits initial state with count', 19 + build: () { 20 + when(() => mockNotificationRepository.getUnreadCount()).thenAnswer((_) async => 5); 21 + return UnreadCountCubit(notificationRepository: mockNotificationRepository); 22 + }, 23 + expect: () => [const UnreadCountState(5)], 24 + ); 25 + 26 + blocTest<UnreadCountCubit, UnreadCountState>( 27 + 'polls unread count on initialization', 28 + build: () { 29 + when(() => mockNotificationRepository.getUnreadCount()).thenAnswer((_) async => 0); 30 + return UnreadCountCubit(notificationRepository: mockNotificationRepository); 31 + }, 32 + expect: () => [const UnreadCountState(0)], 33 + ); 34 + 35 + blocTest<UnreadCountCubit, UnreadCountState>( 36 + 'refresh updates unread count when count changes', 37 + build: () { 38 + when(() => mockNotificationRepository.getUnreadCount()).thenAnswer((_) async => 3); 39 + return UnreadCountCubit(notificationRepository: mockNotificationRepository); 40 + }, 41 + act: (cubit) async { 42 + await Future.delayed(const Duration(milliseconds: 50)); 43 + when(() => mockNotificationRepository.getUnreadCount()).thenAnswer((_) async => 7); 44 + await cubit.refresh(); 45 + }, 46 + expect: () => [const UnreadCountState(3), const UnreadCountState(7)], 47 + ); 48 + 49 + blocTest<UnreadCountCubit, UnreadCountState>( 50 + 'refresh does not emit when count stays the same', 51 + build: () { 52 + when(() => mockNotificationRepository.getUnreadCount()).thenAnswer((_) async => 3); 53 + return UnreadCountCubit(notificationRepository: mockNotificationRepository); 54 + }, 55 + act: (cubit) async { 56 + await Future.delayed(const Duration(milliseconds: 50)); 57 + await cubit.refresh(); 58 + }, 59 + expect: () => [const UnreadCountState(3)], 60 + ); 61 + 62 + blocTest<UnreadCountCubit, UnreadCountState>( 63 + 'silently fails when getUnreadCount throws', 64 + build: () { 65 + when(() => mockNotificationRepository.getUnreadCount()).thenThrow(Exception('Network error')); 66 + return UnreadCountCubit(notificationRepository: mockNotificationRepository); 67 + }, 68 + expect: () => [], 69 + ); 70 + 71 + test('hasUnread returns true when count > 0', () { 72 + const state = UnreadCountState(5); 73 + expect(state.hasUnread, true); 74 + }); 75 + 76 + test('hasUnread returns false when count is 0', () { 77 + const state = UnreadCountState(0); 78 + expect(state.hasUnread, false); 79 + }); 80 + 81 + test('state equality works correctly', () { 82 + expect(const UnreadCountState(5), const UnreadCountState(5)); 83 + expect(const UnreadCountState(5), isNot(const UnreadCountState(3))); 84 + }); 85 + 86 + test('cancels polling timer on close', () async { 87 + when(() => mockNotificationRepository.getUnreadCount()).thenAnswer((_) async => 0); 88 + 89 + final cubit = UnreadCountCubit(notificationRepository: mockNotificationRepository); 90 + 91 + await Future.delayed(const Duration(milliseconds: 100)); 92 + await cubit.close(); 93 + 94 + expect(cubit.isClosed, true); 95 + }); 96 + }); 97 + }
+128
test/features/notifications/data/notification_repository_test.dart
··· 1 + import 'package:atproto_core/atproto_core.dart'; 2 + import 'package:bluesky/app_bsky_actor_defs.dart'; 3 + import 'package:bluesky/app_bsky_notification_listnotifications.dart' as bsky; 4 + import 'package:flutter_test/flutter_test.dart'; 5 + import 'package:lazurite/features/notifications/data/notification_repository.dart'; 6 + import 'package:mocktail/mocktail.dart'; 7 + 8 + class MockNotificationRepository extends Mock implements NotificationRepository {} 9 + 10 + void main() { 11 + late MockNotificationRepository mockRepository; 12 + 13 + setUp(() { 14 + mockRepository = MockNotificationRepository(); 15 + }); 16 + 17 + group('NotificationRepository contract', () { 18 + final sampleNotification = bsky.Notification( 19 + uri: AtUri.parse('at://did:plc:author/app.bsky.feed.post/abc'), 20 + cid: 'cid-123', 21 + author: ProfileView(did: 'did:plc:author', handle: 'author.bsky.social'), 22 + reason: const bsky.NotificationReason.knownValue(data: bsky.KnownNotificationReason.like), 23 + record: {r'$type': 'app.bsky.feed.post', 'text': 'Hello world'}, 24 + isRead: false, 25 + indexedAt: DateTime.utc(2026, 3, 15), 26 + ); 27 + 28 + test('listNotifications returns NotificationListResult with notifications and cursor', () async { 29 + when( 30 + () => mockRepository.listNotifications( 31 + cursor: any(named: 'cursor'), 32 + limit: any(named: 'limit'), 33 + ), 34 + ).thenAnswer((_) async => NotificationListResult(notifications: [sampleNotification], cursor: 'next-cursor')); 35 + 36 + final result = await mockRepository.listNotifications(); 37 + 38 + expect(result.notifications.length, 1); 39 + expect(result.cursor, 'next-cursor'); 40 + expect(result.notifications.first.reason.knownValue, bsky.KnownNotificationReason.like); 41 + }); 42 + 43 + test('listNotifications with cursor returns paginated results', () async { 44 + when( 45 + () => mockRepository.listNotifications( 46 + cursor: 'page-2', 47 + limit: any(named: 'limit'), 48 + ), 49 + ).thenAnswer((_) async => NotificationListResult(notifications: [sampleNotification], cursor: 'page-3')); 50 + 51 + final result = await mockRepository.listNotifications(cursor: 'page-2'); 52 + 53 + expect(result.cursor, 'page-3'); 54 + }); 55 + 56 + test('listNotifications returns empty list when no notifications', () async { 57 + when( 58 + () => mockRepository.listNotifications( 59 + cursor: any(named: 'cursor'), 60 + limit: any(named: 'limit'), 61 + ), 62 + ).thenAnswer((_) async => NotificationListResult(notifications: const [], cursor: null)); 63 + 64 + final result = await mockRepository.listNotifications(); 65 + 66 + expect(result.notifications, isEmpty); 67 + expect(result.cursor, isNull); 68 + }); 69 + 70 + test('getUnreadCount returns count', () async { 71 + when(() => mockRepository.getUnreadCount()).thenAnswer((_) async => 5); 72 + 73 + final result = await mockRepository.getUnreadCount(); 74 + 75 + expect(result, 5); 76 + }); 77 + 78 + test('getUnreadCount returns zero when no unread', () async { 79 + when(() => mockRepository.getUnreadCount()).thenAnswer((_) async => 0); 80 + 81 + final result = await mockRepository.getUnreadCount(); 82 + 83 + expect(result, 0); 84 + }); 85 + 86 + test('updateSeen completes successfully', () async { 87 + when(() => mockRepository.updateSeen()).thenAnswer((_) async {}); 88 + 89 + await mockRepository.updateSeen(); 90 + 91 + verify(() => mockRepository.updateSeen()).called(1); 92 + }); 93 + }); 94 + 95 + group('NotificationListResult', () { 96 + test('stores notifications and cursor', () { 97 + final notification = bsky.Notification( 98 + uri: AtUri.parse('at://did:plc:test/app.bsky.notification/1'), 99 + cid: 'cid', 100 + author: ProfileView(did: 'did:plc:test', handle: 'test.bsky.social'), 101 + reason: const bsky.NotificationReason.knownValue(data: bsky.KnownNotificationReason.follow), 102 + record: {}, 103 + isRead: true, 104 + indexedAt: DateTime.now(), 105 + ); 106 + 107 + final result = NotificationListResult(notifications: [notification], cursor: 'cursor-1'); 108 + 109 + expect(result.notifications.length, 1); 110 + expect(result.cursor, 'cursor-1'); 111 + }); 112 + 113 + test('allows null cursor and seenAt', () { 114 + final result = NotificationListResult(notifications: const [], cursor: null, seenAt: null); 115 + 116 + expect(result.notifications, isEmpty); 117 + expect(result.cursor, isNull); 118 + expect(result.seenAt, isNull); 119 + }); 120 + 121 + test('stores seenAt datetime', () { 122 + final seenAt = DateTime.utc(2026, 3, 15, 10, 30); 123 + final result = NotificationListResult(notifications: const [], cursor: null, seenAt: seenAt); 124 + 125 + expect(result.seenAt, seenAt); 126 + }); 127 + }); 128 + }
+204
test/features/notifications/presentation/notifications_screen_test.dart
··· 1 + import 'package:atproto_core/atproto_core.dart'; 2 + import 'package:bluesky/app_bsky_actor_defs.dart'; 3 + import 'package:bluesky/app_bsky_notification_listnotifications.dart' as bsky; 4 + import 'package:flutter/material.dart'; 5 + import 'package:flutter_bloc/flutter_bloc.dart'; 6 + import 'package:flutter_test/flutter_test.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/data/notification_repository.dart'; 10 + import 'package:lazurite/features/notifications/presentation/notifications_screen.dart'; 11 + import 'package:mocktail/mocktail.dart'; 12 + 13 + class MockNotificationRepository extends Mock implements NotificationRepository {} 14 + 15 + void main() { 16 + group('NotificationsScreen', () { 17 + late MockNotificationRepository mockNotificationRepository; 18 + 19 + setUp(() { 20 + mockNotificationRepository = MockNotificationRepository(); 21 + when( 22 + () => mockNotificationRepository.listNotifications( 23 + cursor: any(named: 'cursor'), 24 + limit: any(named: 'limit'), 25 + ), 26 + ).thenAnswer((_) async => NotificationListResult(notifications: const [], cursor: null)); 27 + when(() => mockNotificationRepository.getUnreadCount()).thenAnswer((_) async => 0); 28 + when(() => mockNotificationRepository.updateSeen()).thenAnswer((_) async {}); 29 + }); 30 + 31 + Widget buildSubject() { 32 + return MaterialApp( 33 + home: MultiBlocProvider( 34 + providers: [ 35 + BlocProvider<NotificationBloc>( 36 + create: (_) => NotificationBloc(notificationRepository: mockNotificationRepository), 37 + ), 38 + BlocProvider<UnreadCountCubit>( 39 + create: (_) => UnreadCountCubit(notificationRepository: mockNotificationRepository), 40 + ), 41 + ], 42 + child: const NotificationsScreen(), 43 + ), 44 + ); 45 + } 46 + 47 + testWidgets('displays loading indicator initially', (tester) async { 48 + await tester.pumpWidget(buildSubject()); 49 + 50 + expect(find.byType(CircularProgressIndicator), findsOneWidget); 51 + }); 52 + 53 + testWidgets('displays notifications when loaded', (tester) async { 54 + final notification = bsky.Notification( 55 + uri: AtUri.parse('at://did:plc:author/app.bsky.feed.post/abc'), 56 + cid: 'cid-123', 57 + author: const ProfileView(did: 'did:plc:author', handle: 'author.bsky.social'), 58 + reason: const bsky.NotificationReason.knownValue(data: bsky.KnownNotificationReason.like), 59 + record: {'text': 'Test post'}, 60 + isRead: false, 61 + indexedAt: DateTime.now(), 62 + ); 63 + 64 + when( 65 + () => mockNotificationRepository.listNotifications( 66 + cursor: any(named: 'cursor'), 67 + limit: any(named: 'limit'), 68 + ), 69 + ).thenAnswer((_) async => NotificationListResult(notifications: [notification], cursor: null)); 70 + 71 + await tester.pumpWidget(buildSubject()); 72 + await tester.pumpAndSettle(); 73 + 74 + expect(find.text('Notifications'), findsOneWidget); 75 + expect(find.byType(ListView), findsOneWidget); 76 + }); 77 + 78 + testWidgets('displays empty state when no notifications', (tester) async { 79 + when( 80 + () => mockNotificationRepository.listNotifications( 81 + cursor: any(named: 'cursor'), 82 + limit: any(named: 'limit'), 83 + ), 84 + ).thenAnswer((_) async => NotificationListResult(notifications: const [], cursor: null)); 85 + 86 + await tester.pumpWidget(buildSubject()); 87 + await tester.pumpAndSettle(); 88 + 89 + expect(find.text('No notifications yet'), findsOneWidget); 90 + }); 91 + 92 + testWidgets('displays error state on failure', (tester) async { 93 + when( 94 + () => mockNotificationRepository.listNotifications( 95 + cursor: any(named: 'cursor'), 96 + limit: any(named: 'limit'), 97 + ), 98 + ).thenThrow(Exception('Network error')); 99 + 100 + await tester.pumpWidget(buildSubject()); 101 + await tester.pumpAndSettle(); 102 + 103 + expect(find.text('Failed to load notifications'), findsOneWidget); 104 + expect(find.text('Retry'), findsOneWidget); 105 + }); 106 + 107 + testWidgets('tapping retry reloads notifications', (tester) async { 108 + when( 109 + () => mockNotificationRepository.listNotifications( 110 + cursor: any(named: 'cursor'), 111 + limit: any(named: 'limit'), 112 + ), 113 + ).thenThrow(Exception('Network error')); 114 + 115 + await tester.pumpWidget(buildSubject()); 116 + await tester.pumpAndSettle(); 117 + 118 + final retryButton = find.text('Retry'); 119 + await tester.tap(retryButton); 120 + await tester.pump(); 121 + 122 + verify( 123 + () => mockNotificationRepository.listNotifications( 124 + cursor: any(named: 'cursor'), 125 + limit: any(named: 'limit'), 126 + ), 127 + ).called(greaterThanOrEqualTo(1)); 128 + }); 129 + 130 + testWidgets('Mark All Read button calls updateSeen', (tester) async { 131 + await tester.pumpWidget(buildSubject()); 132 + await tester.pumpAndSettle(); 133 + 134 + final markAllReadButton = find.text('Mark All Read'); 135 + expect(markAllReadButton, findsOneWidget); 136 + 137 + await tester.tap(markAllReadButton); 138 + await tester.pump(); 139 + 140 + verify(() => mockNotificationRepository.updateSeen()).called(2); 141 + }); 142 + 143 + testWidgets('groups notifications by day', (tester) async { 144 + final todayNotification = bsky.Notification( 145 + uri: AtUri.parse('at://did:plc:author/app.bsky.feed.post/1'), 146 + cid: 'cid-1', 147 + author: const ProfileView(did: 'did:plc:author', handle: 'author.bsky.social'), 148 + reason: const bsky.NotificationReason.knownValue(data: bsky.KnownNotificationReason.like), 149 + record: {'text': 'Post 1'}, 150 + isRead: true, 151 + indexedAt: DateTime.now(), 152 + ); 153 + 154 + final yesterdayNotification = bsky.Notification( 155 + uri: AtUri.parse('at://did:plc:author/app.bsky.feed.post/2'), 156 + cid: 'cid-2', 157 + author: const ProfileView(did: 'did:plc:author2', handle: 'author2.bsky.social'), 158 + reason: const bsky.NotificationReason.knownValue(data: bsky.KnownNotificationReason.follow), 159 + record: {}, 160 + isRead: true, 161 + indexedAt: DateTime.now().subtract(const Duration(days: 1)), 162 + ); 163 + 164 + when( 165 + () => mockNotificationRepository.listNotifications( 166 + cursor: any(named: 'cursor'), 167 + limit: any(named: 'limit'), 168 + ), 169 + ).thenAnswer( 170 + (_) async => NotificationListResult(notifications: [todayNotification, yesterdayNotification], cursor: null), 171 + ); 172 + 173 + await tester.pumpWidget(buildSubject()); 174 + await tester.pumpAndSettle(); 175 + 176 + expect(find.text('Today'), findsOneWidget); 177 + expect(find.text('Yesterday'), findsOneWidget); 178 + }); 179 + 180 + testWidgets('displays day header for older notifications', (tester) async { 181 + final oldNotification = bsky.Notification( 182 + uri: AtUri.parse('at://did:plc:author/app.bsky.feed.post/old'), 183 + cid: 'cid-old', 184 + author: const ProfileView(did: 'did:plc:author', handle: 'author.bsky.social'), 185 + reason: const bsky.NotificationReason.knownValue(data: bsky.KnownNotificationReason.like), 186 + record: {'text': 'Old post'}, 187 + isRead: true, 188 + indexedAt: DateTime(2026, 1, 15), 189 + ); 190 + 191 + when( 192 + () => mockNotificationRepository.listNotifications( 193 + cursor: any(named: 'cursor'), 194 + limit: any(named: 'limit'), 195 + ), 196 + ).thenAnswer((_) async => NotificationListResult(notifications: [oldNotification], cursor: null)); 197 + 198 + await tester.pumpWidget(buildSubject()); 199 + await tester.pumpAndSettle(); 200 + 201 + expect(find.text('January 15'), findsOneWidget); 202 + }); 203 + }); 204 + }
+66 -6
test/features/search/presentation/search_screen_test.dart
··· 1 + import 'package:atproto_core/atproto_core.dart'; 2 + import 'package:bluesky/app_bsky_actor_defs.dart'; 3 + import 'package:bluesky/app_bsky_feed_defs.dart'; 1 4 import 'package:flutter/material.dart'; 2 5 import 'package:flutter_bloc/flutter_bloc.dart'; 3 6 import 'package:flutter_test/flutter_test.dart'; ··· 60 63 expect(find.text('Search posts or people'), findsOneWidget); 61 64 expect(find.text('Posts'), findsOneWidget); 62 65 expect(find.text('People'), findsOneWidget); 63 - expect(find.text('Top'), findsOneWidget); 64 - expect(find.text('Latest'), findsOneWidget); 66 + expect(find.text('Top'), findsNothing); 67 + expect(find.text('Latest'), findsNothing); 65 68 }); 66 69 67 70 testWidgets('shows empty state when no search history', (tester) async { ··· 85 88 await tester.pumpAndSettle(); 86 89 }); 87 90 88 - testWidgets('sort toggle changes correctly', (tester) async { 89 - await tester.pumpWidget(buildSubject()); 91 + testWidgets('sort toggle shows when there are results', (tester) async { 92 + reset(mockSearchRepository); 93 + 94 + final samplePost = PostView( 95 + uri: AtUri.parse('at://did:plc:test/app.bsky.feed.post/1'), 96 + cid: 'cid-1', 97 + author: const ProfileViewBasic(did: 'did:plc:test', handle: 'test.bsky.social'), 98 + record: {r'$type': 'app.bsky.feed.post', 'text': 'Test post', 'createdAt': DateTime.now().toIso8601String()}, 99 + indexedAt: DateTime.now(), 100 + ); 101 + 102 + when( 103 + () => mockSearchRepository.searchPosts( 104 + query: any(named: 'query'), 105 + sort: any(named: 'sort'), 106 + cursor: any(named: 'cursor'), 107 + limit: any(named: 'limit'), 108 + ), 109 + ).thenAnswer((_) async => SearchPostsResult(posts: [samplePost], hitsTotal: 1)); 110 + 111 + when( 112 + () => mockSearchRepository.searchActors( 113 + query: any(named: 'query'), 114 + cursor: any(named: 'cursor'), 115 + limit: any(named: 'limit'), 116 + ), 117 + ).thenAnswer((_) async => SearchActorsResult(actors: [])); 118 + 119 + when( 120 + () => mockSearchRepository.searchActorsTypeahead( 121 + query: any(named: 'query'), 122 + limit: any(named: 'limit'), 123 + ), 124 + ).thenAnswer((_) async => []); 125 + 126 + when(() => mockDatabase.getSearchHistory(any(), limit: any(named: 'limit'))).thenAnswer((_) async => []); 127 + when( 128 + () => mockDatabase.addSearchHistoryEntry( 129 + query: any(named: 'query'), 130 + type: any(named: 'type'), 131 + accountDid: any(named: 'accountDid'), 132 + ), 133 + ).thenAnswer((_) async {}); 134 + 135 + await tester.pumpWidget( 136 + MaterialApp( 137 + home: BlocProvider<SearchBloc>( 138 + create: (_) => 139 + SearchBloc(searchRepository: mockSearchRepository, database: mockDatabase, accountDid: 'did:plc:test'), 140 + child: const SearchScreen(), 141 + ), 142 + ), 143 + ); 90 144 await tester.pumpAndSettle(); 91 145 92 - final latestButton = find.text('Latest'); 93 - await tester.tap(latestButton); 146 + final searchField = find.byType(TextField); 147 + await tester.enterText(searchField, 'test query'); 148 + await tester.pumpAndSettle(); 149 + 150 + await tester.testTextInput.receiveAction(TextInputAction.search); 94 151 await tester.pumpAndSettle(); 152 + 153 + expect(find.text('Top'), findsOneWidget); 154 + expect(find.text('Latest'), findsOneWidget); 95 155 }); 96 156 97 157 testWidgets('shows search history when available', (tester) async {