diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index 2ac7885..ee9057a 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart'; import 'package:bluesky/bluesky.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:lazurite/core/database/app_database.dart'; +import 'package:lazurite/core/logging/app_logger.dart'; import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; import 'package:lazurite/core/router/app_shell.dart'; import 'package:lazurite/features/auth/presentation/login_screen.dart'; @@ -15,6 +16,10 @@ import 'package:lazurite/features/devtools/presentation/dev_tools_screen.dart'; import 'package:lazurite/features/feed/presentation/feed_management_screen.dart'; import 'package:lazurite/features/feed/presentation/home_feed_screen.dart'; import 'package:lazurite/features/logs/presentation/logs_screen.dart'; +import 'package:lazurite/features/notifications/bloc/notification_bloc.dart'; +import 'package:lazurite/features/notifications/cubit/unread_count_cubit.dart'; +import 'package:lazurite/features/notifications/data/notification_repository.dart'; +import 'package:lazurite/features/notifications/presentation/notifications_screen.dart'; import 'package:lazurite/features/profile/presentation/profile_screen.dart'; import 'package:lazurite/features/search/presentation/search_screen.dart'; import 'package:lazurite/features/settings/presentation/about_screen.dart'; @@ -27,6 +32,7 @@ class AppRouter { final GlobalKey _rootNavigatorKey = GlobalKey(debugLabel: 'root'); final GlobalKey _homeNavigatorKey = GlobalKey(debugLabel: 'home'); final GlobalKey _searchNavigatorKey = GlobalKey(debugLabel: 'search'); + final GlobalKey _notificationsNavigatorKey = GlobalKey(debugLabel: 'notifications'); final GlobalKey _profileNavigatorKey = GlobalKey(debugLabel: 'profile'); final GlobalKey _settingsNavigatorKey = GlobalKey(debugLabel: 'settings'); @@ -73,7 +79,28 @@ class AppRouter { }, ), StatefulShellRoute.indexedStack( - builder: (context, state, navigationShell) => AppShell(navigationShell: navigationShell), + builder: (context, state, navigationShell) { + UnreadCountCubit? existingCubit; + try { + existingCubit = context.read(); + } catch (_) { + log.d('UnreadCountCubit not found, creating new one'); + } + + if (existingCubit != null) { + return AppShell(navigationShell: navigationShell); + } + + return MultiBlocProvider( + providers: [ + BlocProvider( + create: (_) => + UnreadCountCubit(notificationRepository: NotificationRepository(bluesky: context.read())), + ), + ], + child: AppShell(navigationShell: navigationShell), + ); + }, branches: [ StatefulShellBranch( navigatorKey: _homeNavigatorKey, @@ -89,6 +116,20 @@ class AppRouter { navigatorKey: _searchNavigatorKey, routes: [GoRoute(path: '/search', builder: (context, state) => const SearchScreen())], ), + StatefulShellBranch( + navigatorKey: _notificationsNavigatorKey, + routes: [ + GoRoute( + path: '/notifications', + builder: (context, state) => BlocProvider( + create: (_) => NotificationBloc( + notificationRepository: NotificationRepository(bluesky: context.read()), + ), + child: const NotificationsScreen(), + ), + ), + ], + ), StatefulShellBranch( navigatorKey: _profileNavigatorKey, routes: [ diff --git a/lib/core/router/app_shell.dart b/lib/core/router/app_shell.dart index 065ebcf..b1405e7 100644 --- a/lib/core/router/app_shell.dart +++ b/lib/core/router/app_shell.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; +import 'package:lazurite/features/notifications/cubit/unread_count_cubit.dart'; class AppShell extends StatelessWidget { const AppShell({super.key, required this.navigationShell}); @@ -22,10 +24,35 @@ class AppShell extends StatelessWidget { ); } - List get _destinations => const [ - NavigationDestination(icon: Icon(Icons.home_outlined), selectedIcon: Icon(Icons.home), label: 'Home'), - NavigationDestination(icon: Icon(Icons.search_outlined), selectedIcon: Icon(Icons.search), label: 'Search'), - NavigationDestination(icon: Icon(Icons.person_outline), selectedIcon: Icon(Icons.person), label: 'Profile'), - NavigationDestination(icon: Icon(Icons.settings_outlined), selectedIcon: Icon(Icons.settings), label: 'Settings'), + List get _destinations => [ + const NavigationDestination(icon: Icon(Icons.home_outlined), selectedIcon: Icon(Icons.home), label: 'Home'), + const NavigationDestination(icon: Icon(Icons.search_outlined), selectedIcon: Icon(Icons.search), label: 'Search'), + NavigationDestination( + icon: BlocBuilder( + builder: (context, state) { + return Badge( + isLabelVisible: state.hasUnread, + label: Text(state.count > 99 ? '99+' : state.count.toString(), style: const TextStyle(fontSize: 10)), + child: const Icon(Icons.notifications_outlined), + ); + }, + ), + selectedIcon: BlocBuilder( + builder: (context, state) { + return Badge( + isLabelVisible: state.hasUnread, + label: Text(state.count > 99 ? '99+' : state.count.toString(), style: const TextStyle(fontSize: 10)), + child: const Icon(Icons.notifications), + ); + }, + ), + label: 'Notifications', + ), + const NavigationDestination(icon: Icon(Icons.person_outline), selectedIcon: Icon(Icons.person), label: 'Profile'), + const NavigationDestination( + icon: Icon(Icons.settings_outlined), + selectedIcon: Icon(Icons.settings), + label: 'Settings', + ), ]; } diff --git a/lib/features/notifications/bloc/notification_bloc.dart b/lib/features/notifications/bloc/notification_bloc.dart new file mode 100644 index 0000000..72a8f29 --- /dev/null +++ b/lib/features/notifications/bloc/notification_bloc.dart @@ -0,0 +1,93 @@ +import 'package:bluesky/app_bsky_notification_listnotifications.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lazurite/core/logging/app_logger.dart'; +import 'package:lazurite/features/notifications/data/notification_repository.dart'; + +part 'notification_event.dart'; +part 'notification_state.dart'; + +class NotificationBloc extends Bloc { + NotificationBloc({required NotificationRepository notificationRepository}) + : _notificationRepository = notificationRepository, + super(const NotificationState.initial()) { + on(_onNotificationsRequested); + on(_onNotificationsRefreshed); + on(_onNotificationsPageLoaded); + on(_onNotificationsMarkedRead); + } + + final NotificationRepository _notificationRepository; + + Future _onNotificationsRequested(NotificationsRequested event, Emitter emit) async { + emit(const NotificationState.loading()); + + try { + final result = await _notificationRepository.listNotifications(limit: event.limit); + + emit( + NotificationState.loaded( + notifications: result.notifications, + cursor: result.cursor, + hasMore: result.cursor != null, + ), + ); + } catch (error) { + emit(NotificationState.error('Failed to load notifications: $error')); + } + } + + Future _onNotificationsRefreshed(NotificationsRefreshed event, Emitter emit) async { + if (state.status != NotificationStatus.loaded) { + return; + } + + emit(state.copyWith(isRefreshing: true)); + + try { + final result = await _notificationRepository.listNotifications(limit: 50); + + emit( + state.copyWith( + notifications: result.notifications, + cursor: result.cursor, + hasMore: result.cursor != null, + isRefreshing: false, + ), + ); + } catch (error) { + emit(state.copyWith(isRefreshing: false)); + } + } + + Future _onNotificationsPageLoaded(NotificationsPageLoaded event, Emitter emit) async { + if (state.status != NotificationStatus.loaded || state.cursor == null || state.isLoadingMore) { + return; + } + + emit(state.copyWith(isLoadingMore: true)); + + try { + final result = await _notificationRepository.listNotifications(cursor: state.cursor, limit: event.limit); + + emit( + state.copyWith( + notifications: [...state.notifications, ...result.notifications], + cursor: result.cursor, + hasMore: result.cursor != null, + isLoadingMore: false, + ), + ); + } catch (error) { + emit(state.copyWith(isLoadingMore: false, hasMore: false)); + } + } + + Future _onNotificationsMarkedRead(NotificationsMarkedRead event, Emitter emit) async { + try { + await _notificationRepository.updateSeen(); + } catch (_) { + log.w('Failed to mark notifications as read/seen'); + } + } +} diff --git a/lib/features/notifications/bloc/notification_event.dart b/lib/features/notifications/bloc/notification_event.dart new file mode 100644 index 0000000..a3a36de --- /dev/null +++ b/lib/features/notifications/bloc/notification_event.dart @@ -0,0 +1,34 @@ +part of 'notification_bloc.dart'; + +sealed class NotificationEvent extends Equatable { + const NotificationEvent(); + + @override + List get props => []; +} + +class NotificationsRequested extends NotificationEvent { + const NotificationsRequested({this.limit = 50}); + + final int limit; + + @override + List get props => [limit]; +} + +class NotificationsRefreshed extends NotificationEvent { + const NotificationsRefreshed(); +} + +class NotificationsPageLoaded extends NotificationEvent { + const NotificationsPageLoaded({this.limit = 50}); + + final int limit; + + @override + List get props => [limit]; +} + +class NotificationsMarkedRead extends NotificationEvent { + const NotificationsMarkedRead(); +} diff --git a/lib/features/notifications/bloc/notification_state.dart b/lib/features/notifications/bloc/notification_state.dart new file mode 100644 index 0000000..9747b01 --- /dev/null +++ b/lib/features/notifications/bloc/notification_state.dart @@ -0,0 +1,55 @@ +part of 'notification_bloc.dart'; + +enum NotificationStatus { initial, loading, loaded, error } + +class NotificationState extends Equatable { + const NotificationState._({ + required this.status, + this.notifications = const [], + this.cursor, + this.hasMore = false, + this.isLoadingMore = false, + this.isRefreshing = false, + this.errorMessage, + }); + + const NotificationState.initial() : this._(status: NotificationStatus.initial); + + const NotificationState.loading() : this._(status: NotificationStatus.loading); + + const NotificationState.loaded({required List notifications, String? cursor, bool hasMore = false}) + : this._(status: NotificationStatus.loaded, notifications: notifications, cursor: cursor, hasMore: hasMore); + + const NotificationState.error(String message) : this._(status: NotificationStatus.error, errorMessage: message); + + final NotificationStatus status; + final List notifications; + final String? cursor; + final bool hasMore; + final bool isLoadingMore; + final bool isRefreshing; + final String? errorMessage; + + NotificationState copyWith({ + NotificationStatus? status, + List? notifications, + String? cursor, + bool? hasMore, + bool? isLoadingMore, + bool? isRefreshing, + String? errorMessage, + }) { + return NotificationState._( + status: status ?? this.status, + notifications: notifications ?? this.notifications, + cursor: cursor ?? this.cursor, + hasMore: hasMore ?? this.hasMore, + isLoadingMore: isLoadingMore ?? this.isLoadingMore, + isRefreshing: isRefreshing ?? this.isRefreshing, + errorMessage: errorMessage ?? this.errorMessage, + ); + } + + @override + List get props => [status, notifications, cursor, hasMore, isLoadingMore, isRefreshing, errorMessage]; +} diff --git a/lib/features/notifications/cubit/unread_count_cubit.dart b/lib/features/notifications/cubit/unread_count_cubit.dart new file mode 100644 index 0000000..731706d --- /dev/null +++ b/lib/features/notifications/cubit/unread_count_cubit.dart @@ -0,0 +1,54 @@ +import 'dart:async'; + +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lazurite/features/notifications/data/notification_repository.dart'; +import 'package:lazurite/core/logging/app_logger.dart'; + +class UnreadCountCubit extends Cubit { + UnreadCountCubit({required NotificationRepository notificationRepository}) + : _notificationRepository = notificationRepository, + super(const UnreadCountState(0)) { + _startPolling(); + } + + final NotificationRepository _notificationRepository; + Timer? _pollingTimer; + + static const _pollingInterval = Duration(seconds: 30); + + void _startPolling() { + _pollUnreadCount(); + _pollingTimer = Timer.periodic(_pollingInterval, (_) => _pollUnreadCount()); + } + + Future _pollUnreadCount() async { + try { + final count = await _notificationRepository.getUnreadCount(); + emit(UnreadCountState(count)); + } catch (_) { + log.w('Failed to poll unread count'); + } + } + + Future refresh() async { + await _pollUnreadCount(); + } + + @override + Future close() { + _pollingTimer?.cancel(); + return super.close(); + } +} + +class UnreadCountState extends Equatable { + const UnreadCountState(this.count); + + final int count; + + bool get hasUnread => count > 0; + + @override + List get props => [count]; +} diff --git a/lib/features/notifications/data/notification_repository.dart b/lib/features/notifications/data/notification_repository.dart new file mode 100644 index 0000000..a3be97a --- /dev/null +++ b/lib/features/notifications/data/notification_repository.dart @@ -0,0 +1,35 @@ +import 'package:bluesky/app_bsky_notification_listnotifications.dart'; +import 'package:bluesky/bluesky.dart'; + +class NotificationRepository { + NotificationRepository({required Bluesky bluesky}) : _bluesky = bluesky; + + final Bluesky _bluesky; + + Future listNotifications({String? cursor, int limit = 50}) async { + final response = await _bluesky.notification.listNotifications(cursor: cursor, limit: limit); + + return NotificationListResult( + notifications: response.data.notifications, + cursor: response.data.cursor, + seenAt: response.data.seenAt, + ); + } + + Future getUnreadCount() async { + final response = await _bluesky.notification.getUnreadCount(); + return response.data.count; + } + + Future updateSeen() async { + await _bluesky.notification.updateSeen(seenAt: DateTime.now()); + } +} + +class NotificationListResult { + NotificationListResult({required this.notifications, this.cursor, this.seenAt}); + + final List notifications; + final String? cursor; + final DateTime? seenAt; +} diff --git a/lib/features/notifications/presentation/notifications_screen.dart b/lib/features/notifications/presentation/notifications_screen.dart new file mode 100644 index 0000000..688f654 --- /dev/null +++ b/lib/features/notifications/presentation/notifications_screen.dart @@ -0,0 +1,219 @@ +import 'package:bluesky/app_bsky_notification_listnotifications.dart' as bsky; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lazurite/features/notifications/bloc/notification_bloc.dart'; +import 'package:lazurite/features/notifications/cubit/unread_count_cubit.dart'; +import 'package:lazurite/features/notifications/presentation/widgets/notification_list_item.dart'; + +class NotificationsScreen extends StatefulWidget { + const NotificationsScreen({super.key}); + + @override + State createState() => _NotificationsScreenState(); +} + +class _NotificationsScreenState extends State { + final ScrollController _scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + _scrollController.addListener(_onScroll); + context.read().add(const NotificationsRequested()); + context.read().add(const NotificationsMarkedRead()); + context.read().refresh(); + } + + @override + void dispose() { + _scrollController.removeListener(_onScroll); + _scrollController.dispose(); + super.dispose(); + } + + void _onScroll() { + if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) { + context.read().add(const NotificationsPageLoaded()); + } + } + + Future _onRefresh() async { + context.read().add(const NotificationsRefreshed()); + context.read().add(const NotificationsMarkedRead()); + await context.read().refresh(); + } + + void _markAllRead() { + context.read().add(const NotificationsMarkedRead()); + context.read().refresh(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Notifications'), + actions: [TextButton(onPressed: _markAllRead, child: const Text('Mark All Read'))], + ), + body: BlocBuilder( + builder: (context, state) { + if (state.status == NotificationStatus.initial || + (state.status == NotificationStatus.loading && state.notifications.isEmpty)) { + return const Center(child: CircularProgressIndicator()); + } + + if (state.status == NotificationStatus.error && state.notifications.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('Failed to load notifications', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + Text(state.errorMessage ?? 'Unknown error', textAlign: TextAlign.center), + const SizedBox(height: 16), + FilledButton( + onPressed: () => context.read().add(const NotificationsRequested()), + child: const Text('Retry'), + ), + ], + ), + ); + } + + if (state.notifications.isEmpty) { + return Center(child: Text('No notifications yet', style: Theme.of(context).textTheme.bodyLarge)); + } + + final groupedNotifications = _groupNotificationsByDay(state.notifications); + + return RefreshIndicator( + onRefresh: _onRefresh, + child: ListView.builder( + controller: _scrollController, + itemCount: _calculateItemCount(groupedNotifications, state), + itemBuilder: (context, index) { + final item = _getItemAtIndex(groupedNotifications, index); + + if (item is String) { + return _DayHeader(title: item); + } else if (item is bsky.Notification) { + return NotificationListItem(notification: item); + } else if (item == null) { + return const Center( + child: Padding(padding: EdgeInsets.all(16), child: CircularProgressIndicator()), + ); + } + + return const SizedBox.shrink(); + }, + ), + ); + }, + ), + ); + } + + Map> _groupNotificationsByDay(List notifications) { + final grouped = >{}; + + for (final notification in notifications) { + final date = DateTime(notification.indexedAt.year, notification.indexedAt.month, notification.indexedAt.day); + + grouped.putIfAbsent(date, () => []).add(notification); + } + + return Map.fromEntries(grouped.entries.toList()..sort((a, b) => b.key.compareTo(a.key))); + } + + int _calculateItemCount(Map> grouped, NotificationState state) { + int count = 0; + for (final entry in grouped.entries) { + count++; + count += entry.value.length; + } + if (state.isLoadingMore) { + count++; + } + return count; + } + + dynamic _getItemAtIndex(Map> grouped, int index) { + int currentIndex = 0; + + for (final entry in grouped.entries) { + if (currentIndex == index) { + return _formatDayHeader(entry.key); + } + currentIndex++; + + for (final notification in entry.value) { + if (currentIndex == index) { + return notification; + } + currentIndex++; + } + } + + return null; + } + + String _formatDayHeader(DateTime date) { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final yesterday = today.subtract(const Duration(days: 1)); + + if (date == today) { + return 'Today'; + } else if (date == yesterday) { + return 'Yesterday'; + } else { + return _formatDate(date); + } + } + + String _formatDate(DateTime date) { + final months = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', + ]; + + return '${months[date.month - 1]} ${date.day}'; + } +} + +class _DayHeader extends StatelessWidget { + const _DayHeader({required this.title}); + + final String title; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: theme.colorScheme.surface, + border: Border(bottom: BorderSide(color: theme.dividerColor)), + ), + child: Text( + title, + style: theme.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurfaceVariant, + letterSpacing: 0.5, + ), + ), + ); + } +} diff --git a/lib/features/notifications/presentation/widgets/notification_list_item.dart b/lib/features/notifications/presentation/widgets/notification_list_item.dart new file mode 100644 index 0000000..d04f2f3 --- /dev/null +++ b/lib/features/notifications/presentation/widgets/notification_list_item.dart @@ -0,0 +1,266 @@ +import 'package:bluesky/app_bsky_notification_listnotifications.dart' as bsky; +import 'package:flutter/material.dart' hide Notification; +import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; + +class NotificationListItem extends StatelessWidget { + const NotificationListItem({super.key, required this.notification}); + + final bsky.Notification notification; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isUnread = !notification.isRead; + + return InkWell( + onTap: () => _onTap(context), + child: Container( + decoration: BoxDecoration( + border: Border(left: isUnread ? BorderSide(color: theme.colorScheme.primary, width: 3) : BorderSide.none), + color: isUnread ? theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5) : null, + ), + child: Padding( + padding: const EdgeInsets.all(14), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildReasonIcon(theme), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildActorRow(), + const SizedBox(height: 4), + _buildSummary(theme), + const SizedBox(height: 2), + _buildTime(theme), + if (_shouldShowPreview) ...[const SizedBox(height: 8), _buildPreview(theme)], + ], + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildReasonIcon(ThemeData theme) { + final reason = notification.reason; + final colorScheme = theme.colorScheme; + + Color backgroundColor; + Color iconColor; + IconData iconData; + + if (reason.isKnownValue) { + switch (reason.knownValue) { + case bsky.KnownNotificationReason.like: + backgroundColor = colorScheme.error.withValues(alpha: 0.1); + iconColor = colorScheme.error; + iconData = Icons.favorite; + case bsky.KnownNotificationReason.repost: + backgroundColor = Colors.green.withValues(alpha: 0.1); + iconColor = Colors.green; + iconData = Icons.repeat; + case bsky.KnownNotificationReason.follow: + backgroundColor = colorScheme.primary.withValues(alpha: 0.1); + iconColor = colorScheme.primary; + iconData = Icons.person_add; + case bsky.KnownNotificationReason.reply: + backgroundColor = colorScheme.secondary.withValues(alpha: 0.1); + iconColor = colorScheme.secondary; + iconData = Icons.chat_bubble; + case bsky.KnownNotificationReason.mention: + backgroundColor = colorScheme.primary.withValues(alpha: 0.1); + iconColor = colorScheme.primary; + iconData = Icons.alternate_email; + case bsky.KnownNotificationReason.quote: + backgroundColor = Colors.purple.withValues(alpha: 0.1); + iconColor = Colors.purple; + iconData = Icons.format_quote; + default: + backgroundColor = colorScheme.surfaceContainerHighest; + iconColor = colorScheme.onSurfaceVariant; + iconData = Icons.notifications; + } + } else { + backgroundColor = colorScheme.surfaceContainerHighest; + iconColor = colorScheme.onSurfaceVariant; + iconData = Icons.notifications; + } + + return Container( + width: 32, + height: 32, + decoration: BoxDecoration(color: backgroundColor, shape: BoxShape.circle), + child: Icon(iconData, size: 16, color: iconColor), + ); + } + + Widget _buildActorRow() { + final author = notification.author; + final avatarUrl = author.avatar; + + return Row( + children: [ + Container( + width: 28, + height: 28, + decoration: BoxDecoration(color: Colors.grey.shade300, shape: BoxShape.circle), + child: avatarUrl != null + ? ClipOval( + child: Image.network( + avatarUrl, + width: 28, + height: 28, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => _buildAvatarPlaceholder(), + ), + ) + : _buildAvatarPlaceholder(), + ), + ], + ); + } + + Widget _buildAvatarPlaceholder() { + final author = notification.author; + final displayName = author.displayName; + final handle = author.handle; + final initials = _getInitials(displayName ?? handle); + + return Center( + child: Text( + initials, + style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: Colors.black54), + ), + ); + } + + String _getInitials(String text) { + final parts = text.split(' '); + if (parts.length >= 2) { + return '${parts[0][0]}${parts[1][0]}'.toUpperCase(); + } + return text.substring(0, text.length >= 2 ? 2 : 1).toUpperCase(); + } + + Widget _buildSummary(ThemeData theme) { + final author = notification.author; + final displayName = author.displayName ?? author.handle; + final reasonText = _getReasonText(); + + return RichText( + text: TextSpan( + children: [ + TextSpan( + text: displayName, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface, + ), + ), + TextSpan( + text: ' $reasonText', + style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + ], + ), + ); + } + + String _getReasonText() { + final reason = notification.reason; + + if (reason.isKnownValue) { + switch (reason.knownValue) { + case bsky.KnownNotificationReason.like: + return 'liked your post'; + case bsky.KnownNotificationReason.repost: + return 'reposted your post'; + case bsky.KnownNotificationReason.follow: + return 'followed you'; + case bsky.KnownNotificationReason.mention: + return 'mentioned you'; + case bsky.KnownNotificationReason.reply: + return 'replied to your post'; + case bsky.KnownNotificationReason.quote: + return 'quoted your post'; + default: + return 'interacted with you'; + } + } + + return 'interacted with you'; + } + + Widget _buildTime(ThemeData theme) { + return Text( + _formatTime(notification.indexedAt), + style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ); + } + + String _formatTime(DateTime time) { + final now = DateTime.now(); + final difference = now.difference(time); + + if (difference.inMinutes < 1) { + return 'Just now'; + } else if (difference.inMinutes < 60) { + return '${difference.inMinutes}m ago'; + } else if (difference.inHours < 24) { + return '${difference.inHours}h ago'; + } else if (difference.inDays < 7) { + return '${difference.inDays}d ago'; + } else { + return DateFormat('MMM d').format(time); + } + } + + bool get _shouldShowPreview { + final reason = notification.reason; + if (reason.isKnownValue) { + return reason.knownValue != bsky.KnownNotificationReason.follow; + } + return false; + } + + Widget _buildPreview(ThemeData theme) { + final record = notification.record; + final text = record['text'] as String?; + + if (text == null || text.isEmpty) { + return const SizedBox.shrink(); + } + + return Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: theme.dividerColor), + ), + child: Text( + text, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + ); + } + + void _onTap(BuildContext context) { + final reason = notification.reason; + + if (reason.isKnownValue && reason.knownValue == bsky.KnownNotificationReason.follow) { + context.push('/profile/view?actor=${notification.author.did}'); + } else { + final uri = notification.uri; + context.push('/post?uri=${Uri.encodeComponent(uri.toString())}'); + } + } +} diff --git a/test/core/router/app_router_test.dart b/test/core/router/app_router_test.dart index 8678d00..835519a 100644 --- a/test/core/router/app_router_test.dart +++ b/test/core/router/app_router_test.dart @@ -9,6 +9,8 @@ import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; import 'package:lazurite/features/auth/data/models/auth_models.dart'; import 'package:lazurite/features/feed/bloc/feed_bloc.dart'; import 'package:lazurite/features/feed/cubit/feed_preferences_cubit.dart'; +import 'package:lazurite/features/notifications/cubit/unread_count_cubit.dart'; +import 'package:lazurite/features/notifications/data/notification_repository.dart'; import 'package:lazurite/features/profile/bloc/profile_bloc.dart'; import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; import 'package:lazurite/features/settings/bloc/settings_state.dart'; @@ -24,12 +26,18 @@ class MockFeedBloc extends MockBloc implements FeedBloc {} class MockSettingsCubit extends MockCubit implements SettingsCubit {} +class MockUnreadCountCubit extends MockCubit implements UnreadCountCubit {} + +class MockNotificationRepository extends Mock implements NotificationRepository {} + void main() { late MockAuthBloc authBloc; late MockFeedPreferencesCubit feedPreferencesCubit; late MockProfileBloc profileBloc; late MockFeedBloc feedBloc; late MockSettingsCubit settingsCubit; + late MockUnreadCountCubit unreadCountCubit; + late MockNotificationRepository notificationRepository; const tokens = AuthTokens( accessToken: 'access', @@ -55,6 +63,8 @@ void main() { profileBloc = MockProfileBloc(); feedBloc = MockFeedBloc(); settingsCubit = MockSettingsCubit(); + unreadCountCubit = MockUnreadCountCubit(); + notificationRepository = MockNotificationRepository(); when(() => authBloc.state).thenReturn(const AuthState.authenticated(tokens)); when(() => feedPreferencesCubit.state).thenReturn(const FeedPreferencesState.loaded(feeds: [])); @@ -69,6 +79,8 @@ void main() { useSystemTheme: false, ), ); + when(() => unreadCountCubit.state).thenReturn(const UnreadCountState(0)); + when(() => notificationRepository.getUnreadCount()).thenAnswer((_) async => 0); whenListen(authBloc, const Stream.empty(), initialState: const AuthState.authenticated(tokens)); whenListen( @@ -96,6 +108,7 @@ void main() { useSystemTheme: false, ), ); + whenListen(unreadCountCubit, const Stream.empty(), initialState: const UnreadCountState(0)); }); Widget buildSubject() { @@ -106,8 +119,12 @@ void main() { BlocProvider.value(value: profileBloc), BlocProvider.value(value: feedBloc), BlocProvider.value(value: settingsCubit), + BlocProvider.value(value: unreadCountCubit), ], - child: MaterialApp.router(routerConfig: AppRouter(authBloc: authBloc).router), + child: RepositoryProvider( + create: (_) => notificationRepository, + child: MaterialApp.router(routerConfig: AppRouter(authBloc: authBloc).router), + ), ); } diff --git a/test/features/notifications/bloc/notification_bloc_test.dart b/test/features/notifications/bloc/notification_bloc_test.dart new file mode 100644 index 0000000..44a113a --- /dev/null +++ b/test/features/notifications/bloc/notification_bloc_test.dart @@ -0,0 +1,204 @@ +import 'package:atproto_core/atproto_core.dart'; +import 'package:bloc_test/bloc_test.dart'; +import 'package:bluesky/app_bsky_actor_defs.dart'; +import 'package:bluesky/app_bsky_notification_listnotifications.dart' as bsky; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/features/notifications/bloc/notification_bloc.dart'; +import 'package:lazurite/features/notifications/data/notification_repository.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockNotificationRepository extends Mock implements NotificationRepository {} + +void main() { + late MockNotificationRepository mockNotificationRepository; + + setUp(() { + mockNotificationRepository = MockNotificationRepository(); + }); + + group('NotificationBloc', () { + final sampleNotification = bsky.Notification( + uri: AtUri.parse('at://did:plc:author/app.bsky.feed.post/abc'), + cid: 'cid-123', + author: ProfileView(did: 'did:plc:author', handle: 'author.bsky.social'), + reason: const bsky.NotificationReason.knownValue(data: bsky.KnownNotificationReason.like), + record: {r'$type': 'app.bsky.feed.post', 'text': 'Hello world'}, + isRead: false, + indexedAt: DateTime.utc(2026, 3, 15), + ); + + blocTest( + 'emits loading and loaded when NotificationsRequested succeeds', + build: () => NotificationBloc(notificationRepository: mockNotificationRepository), + setUp: () { + when( + () => mockNotificationRepository.listNotifications( + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenAnswer((_) async => NotificationListResult(notifications: [sampleNotification], cursor: 'cursor-1')); + }, + act: (bloc) => bloc.add(const NotificationsRequested()), + expect: () => [ + const NotificationState.loading(), + predicate( + (state) => + state.status == NotificationStatus.loaded && + state.notifications.length == 1 && + state.cursor == 'cursor-1' && + state.hasMore == true, + ), + ], + ); + + blocTest( + 'emits error when NotificationsRequested fails', + build: () => NotificationBloc(notificationRepository: mockNotificationRepository), + setUp: () { + when( + () => mockNotificationRepository.listNotifications( + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenThrow(Exception('Network error')); + }, + act: (bloc) => bloc.add(const NotificationsRequested()), + expect: () => [ + const NotificationState.loading(), + predicate((state) => state.status == NotificationStatus.error), + ], + ); + + blocTest( + 'loads more notifications on NotificationsPageLoaded', + build: () => NotificationBloc(notificationRepository: mockNotificationRepository), + seed: () => NotificationState.loaded(notifications: [sampleNotification], cursor: 'cursor-1', hasMore: true), + setUp: () { + final secondNotification = bsky.Notification( + uri: AtUri.parse('at://did:plc:author2/app.bsky.feed.post/def'), + cid: 'cid-456', + author: ProfileView(did: 'did:plc:author2', handle: 'author2.bsky.social'), + reason: const bsky.NotificationReason.knownValue(data: bsky.KnownNotificationReason.follow), + record: {}, + isRead: true, + indexedAt: DateTime.utc(2026, 3, 14), + ); + when( + () => mockNotificationRepository.listNotifications( + cursor: 'cursor-1', + limit: any(named: 'limit'), + ), + ).thenAnswer((_) async => NotificationListResult(notifications: [secondNotification], cursor: null)); + }, + act: (bloc) => bloc.add(const NotificationsPageLoaded()), + expect: () => [ + predicate((state) => state.isLoadingMore), + predicate( + (state) => + state.status == NotificationStatus.loaded && + state.notifications.length == 2 && + !state.hasMore && + !state.isLoadingMore, + ), + ], + verify: (_) { + verify( + () => mockNotificationRepository.listNotifications( + cursor: 'cursor-1', + limit: any(named: 'limit'), + ), + ).called(1); + }, + ); + + blocTest( + 'does not load more when already loading more', + build: () => NotificationBloc(notificationRepository: mockNotificationRepository), + seed: () => NotificationState.loaded( + notifications: [sampleNotification], + cursor: 'cursor-1', + hasMore: true, + ).copyWith(isLoadingMore: true), + act: (bloc) => bloc.add(const NotificationsPageLoaded()), + expect: () => [], + verify: (_) { + verifyNever( + () => mockNotificationRepository.listNotifications( + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ); + }, + ); + + blocTest( + 'does not load more when no cursor', + build: () => NotificationBloc(notificationRepository: mockNotificationRepository), + seed: () => NotificationState.loaded(notifications: [sampleNotification], cursor: null, hasMore: false), + act: (bloc) => bloc.add(const NotificationsPageLoaded()), + expect: () => [], + verify: (_) { + verifyNever( + () => mockNotificationRepository.listNotifications( + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ); + }, + ); + + blocTest( + 'refreshes notifications on NotificationsRefreshed', + build: () => NotificationBloc(notificationRepository: mockNotificationRepository), + seed: () => NotificationState.loaded(notifications: [sampleNotification], cursor: 'old-cursor', hasMore: true), + setUp: () { + final newNotification = bsky.Notification( + uri: AtUri.parse('at://did:plc:new/app.bsky.feed.post/new'), + cid: 'cid-new', + author: ProfileView(did: 'did:plc:new', handle: 'new.bsky.social'), + reason: const bsky.NotificationReason.knownValue(data: bsky.KnownNotificationReason.repost), + record: {}, + isRead: false, + indexedAt: DateTime.utc(2026, 3, 16), + ); + when( + () => mockNotificationRepository.listNotifications(limit: any(named: 'limit')), + ).thenAnswer((_) async => NotificationListResult(notifications: [newNotification], cursor: 'new-cursor')); + }, + act: (bloc) => bloc.add(const NotificationsRefreshed()), + expect: () => [ + predicate((state) => state.isRefreshing), + predicate( + (state) => + state.status == NotificationStatus.loaded && + state.notifications.length == 1 && + state.cursor == 'new-cursor' && + !state.isRefreshing, + ), + ], + ); + + blocTest( + 'calls updateSeen on NotificationsMarkedRead', + build: () => NotificationBloc(notificationRepository: mockNotificationRepository), + setUp: () { + when(() => mockNotificationRepository.updateSeen()).thenAnswer((_) async {}); + }, + act: (bloc) => bloc.add(const NotificationsMarkedRead()), + expect: () => [], + verify: (_) { + verify(() => mockNotificationRepository.updateSeen()).called(1); + }, + ); + + blocTest( + 'handles updateSeen failure silently', + build: () => NotificationBloc(notificationRepository: mockNotificationRepository), + setUp: () { + when(() => mockNotificationRepository.updateSeen()).thenThrow(Exception('Network error')); + }, + act: (bloc) => bloc.add(const NotificationsMarkedRead()), + expect: () => [], + ); + }); +} diff --git a/test/features/notifications/cubit/unread_count_cubit_test.dart b/test/features/notifications/cubit/unread_count_cubit_test.dart new file mode 100644 index 0000000..adc10ce --- /dev/null +++ b/test/features/notifications/cubit/unread_count_cubit_test.dart @@ -0,0 +1,97 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/features/notifications/cubit/unread_count_cubit.dart'; +import 'package:lazurite/features/notifications/data/notification_repository.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockNotificationRepository extends Mock implements NotificationRepository {} + +void main() { + late MockNotificationRepository mockNotificationRepository; + + setUp(() { + mockNotificationRepository = MockNotificationRepository(); + }); + + group('UnreadCountCubit', () { + blocTest( + 'emits initial state with count', + build: () { + when(() => mockNotificationRepository.getUnreadCount()).thenAnswer((_) async => 5); + return UnreadCountCubit(notificationRepository: mockNotificationRepository); + }, + expect: () => [const UnreadCountState(5)], + ); + + blocTest( + 'polls unread count on initialization', + build: () { + when(() => mockNotificationRepository.getUnreadCount()).thenAnswer((_) async => 0); + return UnreadCountCubit(notificationRepository: mockNotificationRepository); + }, + expect: () => [const UnreadCountState(0)], + ); + + blocTest( + 'refresh updates unread count when count changes', + build: () { + when(() => mockNotificationRepository.getUnreadCount()).thenAnswer((_) async => 3); + return UnreadCountCubit(notificationRepository: mockNotificationRepository); + }, + act: (cubit) async { + await Future.delayed(const Duration(milliseconds: 50)); + when(() => mockNotificationRepository.getUnreadCount()).thenAnswer((_) async => 7); + await cubit.refresh(); + }, + expect: () => [const UnreadCountState(3), const UnreadCountState(7)], + ); + + blocTest( + 'refresh does not emit when count stays the same', + build: () { + when(() => mockNotificationRepository.getUnreadCount()).thenAnswer((_) async => 3); + return UnreadCountCubit(notificationRepository: mockNotificationRepository); + }, + act: (cubit) async { + await Future.delayed(const Duration(milliseconds: 50)); + await cubit.refresh(); + }, + expect: () => [const UnreadCountState(3)], + ); + + blocTest( + 'silently fails when getUnreadCount throws', + build: () { + when(() => mockNotificationRepository.getUnreadCount()).thenThrow(Exception('Network error')); + return UnreadCountCubit(notificationRepository: mockNotificationRepository); + }, + expect: () => [], + ); + + test('hasUnread returns true when count > 0', () { + const state = UnreadCountState(5); + expect(state.hasUnread, true); + }); + + test('hasUnread returns false when count is 0', () { + const state = UnreadCountState(0); + expect(state.hasUnread, false); + }); + + test('state equality works correctly', () { + expect(const UnreadCountState(5), const UnreadCountState(5)); + expect(const UnreadCountState(5), isNot(const UnreadCountState(3))); + }); + + test('cancels polling timer on close', () async { + when(() => mockNotificationRepository.getUnreadCount()).thenAnswer((_) async => 0); + + final cubit = UnreadCountCubit(notificationRepository: mockNotificationRepository); + + await Future.delayed(const Duration(milliseconds: 100)); + await cubit.close(); + + expect(cubit.isClosed, true); + }); + }); +} diff --git a/test/features/notifications/data/notification_repository_test.dart b/test/features/notifications/data/notification_repository_test.dart new file mode 100644 index 0000000..83906e8 --- /dev/null +++ b/test/features/notifications/data/notification_repository_test.dart @@ -0,0 +1,128 @@ +import 'package:atproto_core/atproto_core.dart'; +import 'package:bluesky/app_bsky_actor_defs.dart'; +import 'package:bluesky/app_bsky_notification_listnotifications.dart' as bsky; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/features/notifications/data/notification_repository.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockNotificationRepository extends Mock implements NotificationRepository {} + +void main() { + late MockNotificationRepository mockRepository; + + setUp(() { + mockRepository = MockNotificationRepository(); + }); + + group('NotificationRepository contract', () { + final sampleNotification = bsky.Notification( + uri: AtUri.parse('at://did:plc:author/app.bsky.feed.post/abc'), + cid: 'cid-123', + author: ProfileView(did: 'did:plc:author', handle: 'author.bsky.social'), + reason: const bsky.NotificationReason.knownValue(data: bsky.KnownNotificationReason.like), + record: {r'$type': 'app.bsky.feed.post', 'text': 'Hello world'}, + isRead: false, + indexedAt: DateTime.utc(2026, 3, 15), + ); + + test('listNotifications returns NotificationListResult with notifications and cursor', () async { + when( + () => mockRepository.listNotifications( + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenAnswer((_) async => NotificationListResult(notifications: [sampleNotification], cursor: 'next-cursor')); + + final result = await mockRepository.listNotifications(); + + expect(result.notifications.length, 1); + expect(result.cursor, 'next-cursor'); + expect(result.notifications.first.reason.knownValue, bsky.KnownNotificationReason.like); + }); + + test('listNotifications with cursor returns paginated results', () async { + when( + () => mockRepository.listNotifications( + cursor: 'page-2', + limit: any(named: 'limit'), + ), + ).thenAnswer((_) async => NotificationListResult(notifications: [sampleNotification], cursor: 'page-3')); + + final result = await mockRepository.listNotifications(cursor: 'page-2'); + + expect(result.cursor, 'page-3'); + }); + + test('listNotifications returns empty list when no notifications', () async { + when( + () => mockRepository.listNotifications( + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenAnswer((_) async => NotificationListResult(notifications: const [], cursor: null)); + + final result = await mockRepository.listNotifications(); + + expect(result.notifications, isEmpty); + expect(result.cursor, isNull); + }); + + test('getUnreadCount returns count', () async { + when(() => mockRepository.getUnreadCount()).thenAnswer((_) async => 5); + + final result = await mockRepository.getUnreadCount(); + + expect(result, 5); + }); + + test('getUnreadCount returns zero when no unread', () async { + when(() => mockRepository.getUnreadCount()).thenAnswer((_) async => 0); + + final result = await mockRepository.getUnreadCount(); + + expect(result, 0); + }); + + test('updateSeen completes successfully', () async { + when(() => mockRepository.updateSeen()).thenAnswer((_) async {}); + + await mockRepository.updateSeen(); + + verify(() => mockRepository.updateSeen()).called(1); + }); + }); + + group('NotificationListResult', () { + test('stores notifications and cursor', () { + final notification = bsky.Notification( + uri: AtUri.parse('at://did:plc:test/app.bsky.notification/1'), + cid: 'cid', + author: ProfileView(did: 'did:plc:test', handle: 'test.bsky.social'), + reason: const bsky.NotificationReason.knownValue(data: bsky.KnownNotificationReason.follow), + record: {}, + isRead: true, + indexedAt: DateTime.now(), + ); + + final result = NotificationListResult(notifications: [notification], cursor: 'cursor-1'); + + expect(result.notifications.length, 1); + expect(result.cursor, 'cursor-1'); + }); + + test('allows null cursor and seenAt', () { + final result = NotificationListResult(notifications: const [], cursor: null, seenAt: null); + + expect(result.notifications, isEmpty); + expect(result.cursor, isNull); + expect(result.seenAt, isNull); + }); + + test('stores seenAt datetime', () { + final seenAt = DateTime.utc(2026, 3, 15, 10, 30); + final result = NotificationListResult(notifications: const [], cursor: null, seenAt: seenAt); + + expect(result.seenAt, seenAt); + }); + }); +} diff --git a/test/features/notifications/presentation/notifications_screen_test.dart b/test/features/notifications/presentation/notifications_screen_test.dart new file mode 100644 index 0000000..ccbd612 --- /dev/null +++ b/test/features/notifications/presentation/notifications_screen_test.dart @@ -0,0 +1,204 @@ +import 'package:atproto_core/atproto_core.dart'; +import 'package:bluesky/app_bsky_actor_defs.dart'; +import 'package:bluesky/app_bsky_notification_listnotifications.dart' as bsky; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/features/notifications/bloc/notification_bloc.dart'; +import 'package:lazurite/features/notifications/cubit/unread_count_cubit.dart'; +import 'package:lazurite/features/notifications/data/notification_repository.dart'; +import 'package:lazurite/features/notifications/presentation/notifications_screen.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockNotificationRepository extends Mock implements NotificationRepository {} + +void main() { + group('NotificationsScreen', () { + late MockNotificationRepository mockNotificationRepository; + + setUp(() { + mockNotificationRepository = MockNotificationRepository(); + when( + () => mockNotificationRepository.listNotifications( + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenAnswer((_) async => NotificationListResult(notifications: const [], cursor: null)); + when(() => mockNotificationRepository.getUnreadCount()).thenAnswer((_) async => 0); + when(() => mockNotificationRepository.updateSeen()).thenAnswer((_) async {}); + }); + + Widget buildSubject() { + return MaterialApp( + home: MultiBlocProvider( + providers: [ + BlocProvider( + create: (_) => NotificationBloc(notificationRepository: mockNotificationRepository), + ), + BlocProvider( + create: (_) => UnreadCountCubit(notificationRepository: mockNotificationRepository), + ), + ], + child: const NotificationsScreen(), + ), + ); + } + + testWidgets('displays loading indicator initially', (tester) async { + await tester.pumpWidget(buildSubject()); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); + + testWidgets('displays notifications when loaded', (tester) async { + final notification = bsky.Notification( + uri: AtUri.parse('at://did:plc:author/app.bsky.feed.post/abc'), + cid: 'cid-123', + author: const ProfileView(did: 'did:plc:author', handle: 'author.bsky.social'), + reason: const bsky.NotificationReason.knownValue(data: bsky.KnownNotificationReason.like), + record: {'text': 'Test post'}, + isRead: false, + indexedAt: DateTime.now(), + ); + + when( + () => mockNotificationRepository.listNotifications( + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenAnswer((_) async => NotificationListResult(notifications: [notification], cursor: null)); + + await tester.pumpWidget(buildSubject()); + await tester.pumpAndSettle(); + + expect(find.text('Notifications'), findsOneWidget); + expect(find.byType(ListView), findsOneWidget); + }); + + testWidgets('displays empty state when no notifications', (tester) async { + when( + () => mockNotificationRepository.listNotifications( + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenAnswer((_) async => NotificationListResult(notifications: const [], cursor: null)); + + await tester.pumpWidget(buildSubject()); + await tester.pumpAndSettle(); + + expect(find.text('No notifications yet'), findsOneWidget); + }); + + testWidgets('displays error state on failure', (tester) async { + when( + () => mockNotificationRepository.listNotifications( + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenThrow(Exception('Network error')); + + await tester.pumpWidget(buildSubject()); + await tester.pumpAndSettle(); + + expect(find.text('Failed to load notifications'), findsOneWidget); + expect(find.text('Retry'), findsOneWidget); + }); + + testWidgets('tapping retry reloads notifications', (tester) async { + when( + () => mockNotificationRepository.listNotifications( + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenThrow(Exception('Network error')); + + await tester.pumpWidget(buildSubject()); + await tester.pumpAndSettle(); + + final retryButton = find.text('Retry'); + await tester.tap(retryButton); + await tester.pump(); + + verify( + () => mockNotificationRepository.listNotifications( + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).called(greaterThanOrEqualTo(1)); + }); + + testWidgets('Mark All Read button calls updateSeen', (tester) async { + await tester.pumpWidget(buildSubject()); + await tester.pumpAndSettle(); + + final markAllReadButton = find.text('Mark All Read'); + expect(markAllReadButton, findsOneWidget); + + await tester.tap(markAllReadButton); + await tester.pump(); + + verify(() => mockNotificationRepository.updateSeen()).called(2); + }); + + testWidgets('groups notifications by day', (tester) async { + final todayNotification = bsky.Notification( + uri: AtUri.parse('at://did:plc:author/app.bsky.feed.post/1'), + cid: 'cid-1', + author: const ProfileView(did: 'did:plc:author', handle: 'author.bsky.social'), + reason: const bsky.NotificationReason.knownValue(data: bsky.KnownNotificationReason.like), + record: {'text': 'Post 1'}, + isRead: true, + indexedAt: DateTime.now(), + ); + + final yesterdayNotification = bsky.Notification( + uri: AtUri.parse('at://did:plc:author/app.bsky.feed.post/2'), + cid: 'cid-2', + author: const ProfileView(did: 'did:plc:author2', handle: 'author2.bsky.social'), + reason: const bsky.NotificationReason.knownValue(data: bsky.KnownNotificationReason.follow), + record: {}, + isRead: true, + indexedAt: DateTime.now().subtract(const Duration(days: 1)), + ); + + when( + () => mockNotificationRepository.listNotifications( + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenAnswer( + (_) async => NotificationListResult(notifications: [todayNotification, yesterdayNotification], cursor: null), + ); + + await tester.pumpWidget(buildSubject()); + await tester.pumpAndSettle(); + + expect(find.text('Today'), findsOneWidget); + expect(find.text('Yesterday'), findsOneWidget); + }); + + testWidgets('displays day header for older notifications', (tester) async { + final oldNotification = bsky.Notification( + uri: AtUri.parse('at://did:plc:author/app.bsky.feed.post/old'), + cid: 'cid-old', + author: const ProfileView(did: 'did:plc:author', handle: 'author.bsky.social'), + reason: const bsky.NotificationReason.knownValue(data: bsky.KnownNotificationReason.like), + record: {'text': 'Old post'}, + isRead: true, + indexedAt: DateTime(2026, 1, 15), + ); + + when( + () => mockNotificationRepository.listNotifications( + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenAnswer((_) async => NotificationListResult(notifications: [oldNotification], cursor: null)); + + await tester.pumpWidget(buildSubject()); + await tester.pumpAndSettle(); + + expect(find.text('January 15'), findsOneWidget); + }); + }); +} diff --git a/test/features/search/presentation/search_screen_test.dart b/test/features/search/presentation/search_screen_test.dart index 4c2dfea..b2ea295 100644 --- a/test/features/search/presentation/search_screen_test.dart +++ b/test/features/search/presentation/search_screen_test.dart @@ -1,3 +1,6 @@ +import 'package:atproto_core/atproto_core.dart'; +import 'package:bluesky/app_bsky_actor_defs.dart'; +import 'package:bluesky/app_bsky_feed_defs.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -60,8 +63,8 @@ void main() { expect(find.text('Search posts or people'), findsOneWidget); expect(find.text('Posts'), findsOneWidget); expect(find.text('People'), findsOneWidget); - expect(find.text('Top'), findsOneWidget); - expect(find.text('Latest'), findsOneWidget); + expect(find.text('Top'), findsNothing); + expect(find.text('Latest'), findsNothing); }); testWidgets('shows empty state when no search history', (tester) async { @@ -85,13 +88,70 @@ void main() { await tester.pumpAndSettle(); }); - testWidgets('sort toggle changes correctly', (tester) async { - await tester.pumpWidget(buildSubject()); + testWidgets('sort toggle shows when there are results', (tester) async { + reset(mockSearchRepository); + + final samplePost = PostView( + uri: AtUri.parse('at://did:plc:test/app.bsky.feed.post/1'), + cid: 'cid-1', + author: const ProfileViewBasic(did: 'did:plc:test', handle: 'test.bsky.social'), + record: {r'$type': 'app.bsky.feed.post', 'text': 'Test post', 'createdAt': DateTime.now().toIso8601String()}, + indexedAt: DateTime.now(), + ); + + when( + () => mockSearchRepository.searchPosts( + query: any(named: 'query'), + sort: any(named: 'sort'), + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenAnswer((_) async => SearchPostsResult(posts: [samplePost], hitsTotal: 1)); + + when( + () => mockSearchRepository.searchActors( + query: any(named: 'query'), + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenAnswer((_) async => SearchActorsResult(actors: [])); + + when( + () => mockSearchRepository.searchActorsTypeahead( + query: any(named: 'query'), + limit: any(named: 'limit'), + ), + ).thenAnswer((_) async => []); + + when(() => mockDatabase.getSearchHistory(any(), limit: any(named: 'limit'))).thenAnswer((_) async => []); + when( + () => mockDatabase.addSearchHistoryEntry( + query: any(named: 'query'), + type: any(named: 'type'), + accountDid: any(named: 'accountDid'), + ), + ).thenAnswer((_) async {}); + + await tester.pumpWidget( + MaterialApp( + home: BlocProvider( + create: (_) => + SearchBloc(searchRepository: mockSearchRepository, database: mockDatabase, accountDid: 'did:plc:test'), + child: const SearchScreen(), + ), + ), + ); + await tester.pumpAndSettle(); + + final searchField = find.byType(TextField); + await tester.enterText(searchField, 'test query'); await tester.pumpAndSettle(); - final latestButton = find.text('Latest'); - await tester.tap(latestButton); + await tester.testTextInput.receiveAction(TextInputAction.search); await tester.pumpAndSettle(); + + expect(find.text('Top'), findsOneWidget); + expect(find.text('Latest'), findsOneWidget); }); testWidgets('shows search history when available', (tester) async {