import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../services/auth_service.dart'; import 'package:timeago/timeago.dart' as timeago; import '../../models/notification.dart'; import '../../providers/api_provider.dart'; import '../../theme/app_theme.dart'; import '../../widgets/media/signed_media_image.dart'; import '../profile/viewable_profile_screen.dart'; import '../post/post_detail_screen.dart'; import 'package:go_router/go_router.dart'; import 'package:google_fonts/google_fonts.dart'; import '../../services/notification_service.dart'; import '../../providers/notification_provider.dart'; import '../secure_chat/secure_chat_full_screen.dart'; /// Notifications screen showing user activity class NotificationsScreen extends ConsumerStatefulWidget { const NotificationsScreen({super.key}); @override ConsumerState createState() => _NotificationsScreenState(); } class _NotificationsScreenState extends ConsumerState { List _notifications = []; bool _isLoading = false; bool _hasMore = true; String? _error; int _activeTabIndex = 0; // 0 for Active, 1 for Archived final Set _locallyArchivedIds = {}; StreamSubscription? _authSub; TabController? _tabController; @override void initState() { super.initState(); final auth = AuthService.instance; if (!auth.isAuthenticated) { _authSub = auth.authStateChanges.listen((event) { if (event.event == AuthChangeEvent.signedIn || event.event == AuthChangeEvent.tokenRefreshed) { _loadNotifications(); } }); } else { _loadNotifications(); } } @override void dispose() { _authSub?.cancel(); _tabController?.dispose(); NotificationService.instance.refreshBadge(); super.dispose(); } void _subscribeToNotifications() { // Migrate to Go WebSockets or Polling } Future _handleNewNotification(Map record) async { // Fetch the full notification with actor and post data try { final apiService = ref.read(apiServiceProvider); final notifications = await apiService.getNotifications(limit: 1, offset: 0); if (notifications.isNotEmpty && mounted) { final newNotification = notifications.first; if (_activeTabIndex != 1 && // If not in Archived tab (newNotification.archivedAt != null || _locallyArchivedIds.contains(newNotification.id))) { return; } // Only add if not already in the list if (!_notifications.any((n) => n.id == newNotification.id)) { setState(() { _notifications.insert(0, newNotification); }); } } } catch (e) { // Silently fail - we'll get it on next refresh } } Future _loadNotifications({bool refresh = false}) async { if (_isLoading) return; // Ensure we have a valid session before loading if (!AuthService.instance.isAuthenticated) return; setState(() { _isLoading = true; _error = null; if (refresh) { _notifications = []; _hasMore = true; } }); try { final apiService = ref.read(apiServiceProvider); final showArchived = _activeTabIndex == 1; final notifications = await apiService.getNotifications( limit: 20, offset: refresh ? 0 : _notifications.length, includeArchived: showArchived, ); if (mounted) { // Backend handles active vs archived filtering final filtered = notifications .where((item) => !_locallyArchivedIds.contains(item.id)) .toList(); setState(() { if (refresh) { _notifications = filtered; } else { _notifications.addAll(filtered); } _hasMore = notifications.length == 20; }); } } catch (e) { if (mounted) { setState(() { _error = e.toString().replaceAll('Exception: ', ''); }); } } finally { if (mounted) { setState(() { _isLoading = false; }); } } } Future _markAsRead(AppNotification notification) async { if (notification.isRead) return; try { final apiService = ref.read(apiServiceProvider); await apiService.markNotificationsAsRead([notification.id]); if (mounted) { setState(() { final index = _notifications.indexWhere((item) => item.id == notification.id); if (index != -1) { _notifications[index] = notification.copyWith(isRead: true); } }); // Clear the badge count on the bell icon immediately NotificationService.instance.refreshBadge(); } } catch (e) { // Silently fail - not critical } } Future _archiveAllNotifications() async { final confirmed = await showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Archive All'), content: const Text('Are you sure you want to move all notifications to your archive?'), actions: [ TextButton( onPressed: () => Navigator.pop(context, false), child: const Text('Cancel'), ), TextButton( onPressed: () => Navigator.pop(context, true), child: Text('Archive All', style: TextStyle(color: AppTheme.error)), ), ], ), ); if (confirmed != true) return; try { final apiService = ref.read(apiServiceProvider); await apiService.archiveAllNotifications(); if (mounted) { setState(() { _locallyArchivedIds.addAll(_notifications.map((n) => n.id)); _notifications = []; _hasMore = false; }); ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Notifications moved to archive'), duration: Duration(seconds: 2), ), ); // Clear the badge count on the bell icon immediately NotificationService.instance.refreshBadge(); } } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Failed to archive notifications: ${e.toString().replaceAll('Exception: ', '')}'), backgroundColor: AppTheme.error, ), ); } } } Future _approveFollowRequest(AppNotification notification) async { final requesterId = notification.followerIdFromMetadata ?? notification.actor?.id; if (requesterId == null) return; try { final apiService = ref.read(apiServiceProvider); await apiService.acceptFollowRequest(requesterId); await _archiveNotification(notification); } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Failed to approve request: ${e.toString().replaceAll('Exception: ', '')}'), backgroundColor: AppTheme.error, ), ); } } } Future _rejectFollowRequest(AppNotification notification) async { final requesterId = notification.followerIdFromMetadata ?? notification.actor?.id; if (requesterId == null) return; try { final apiService = ref.read(apiServiceProvider); await apiService.rejectFollowRequest(requesterId); await _archiveNotification(notification); } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Failed to delete request: ${e.toString().replaceAll('Exception: ', '')}'), backgroundColor: AppTheme.error, ), ); } } } void _handleNotificationTap(AppNotification notification) async { // Only mark as read, do NOT archive automatically on tap. // This allows the user to see the notification again later if they don't archive it. await _markAsRead(notification); // Navigate based on notification type switch (notification.type) { case NotificationType.follow: case NotificationType.follow_accepted: // Navigate to the follower's profile if (notification.actor != null) { Navigator.of(context).push( MaterialPageRoute( builder: (_) => ViewableProfileScreen(handle: notification.actor!.handle), ), ); } break; case NotificationType.like: case NotificationType.comment: case NotificationType.reply: case NotificationType.mention: // Fetch the post and navigate to post detail if (notification.postId != null) { try { final apiService = ref.read(apiServiceProvider); final post = await apiService.getPostById(notification.postId!); if (mounted) { Navigator.of(context, rootNavigator: true).push( MaterialPageRoute( builder: (_) => PostDetailScreen(post: post), ), ); } } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Failed to load post: ${e.toString().replaceAll('Exception: ', '')}'), backgroundColor: AppTheme.error, ), ); } } } break; case NotificationType.message: // For messages, navigate to chat screen if (notification.metadata?['conversation_id'] != null) { context.push('/secure-chat/${notification.metadata!['conversation_id']}'); } else { context.push('/secure-chat'); } break; case NotificationType.follow_request: break; default: break; } } Future _archiveNotification(AppNotification notification) async { try { final apiService = ref.read(apiServiceProvider); await apiService.archiveNotifications([notification.id]); if (mounted) { setState(() { _locallyArchivedIds.add(notification.id); _notifications.removeWhere((n) => n.id == notification.id); }); // Update badge count NotificationService.instance.refreshBadge(); } return true; } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Failed to archive: ${e.toString().replaceAll('Exception: ', '')}'), backgroundColor: AppTheme.error, ), ); } return false; } } @override Widget build(BuildContext context) { final canArchiveAll = _activeTabIndex == 0 && _notifications.isNotEmpty; return DefaultTabController( length: 2, child: Scaffold( backgroundColor: AppTheme.scaffoldBg, appBar: AppBar( backgroundColor: AppTheme.scaffoldBg, elevation: 0, surfaceTintColor: Colors.transparent, leading: IconButton( onPressed: () { if (Navigator.of(context).canPop()) { Navigator.of(context).pop(); } else { context.go('/home'); } }, icon: Icon(Icons.arrow_back, color: AppTheme.navyBlue), ), title: Text( 'Activity', style: GoogleFonts.inter( color: AppTheme.textPrimary, fontSize: 18, fontWeight: FontWeight.w700, ), ), actions: [ if (canArchiveAll) TextButton( onPressed: _archiveAllNotifications, child: Text( 'Archive All', style: AppTheme.textTheme.labelMedium?.copyWith( color: AppTheme.egyptianBlue, fontWeight: FontWeight.w600, ), ), ), IconButton( onPressed: () => context.go('/home'), icon: Icon(Icons.home_outlined, color: AppTheme.navyBlue), ), IconButton( onPressed: () => Navigator.of(context, rootNavigator: true).push( MaterialPageRoute( builder: (_) => const SecureChatFullScreen(), fullscreenDialog: true, ), ), icon: Consumer( builder: (context, ref, child) { final badge = ref.watch(currentBadgeProvider); return Badge( label: Text(badge.messageCount.toString()), isLabelVisible: badge.messageCount > 0, backgroundColor: AppTheme.brightNavy, child: Icon(Icons.chat_bubble_outline, color: AppTheme.navyBlue), ); }, ), ), const SizedBox(width: 8), ], bottom: TabBar( onTap: (index) { if (index != _activeTabIndex) { setState(() { _activeTabIndex = index; }); _loadNotifications(refresh: true); } }, indicatorColor: AppTheme.egyptianBlue, labelColor: AppTheme.egyptianBlue, unselectedLabelColor: AppTheme.egyptianBlue.withOpacity(0.5), tabs: const [ Tab(text: 'Active'), Tab(text: 'Archived'), ], ), ), body: _error != null ? _ErrorState( message: _error!, onRetry: () => _loadNotifications(refresh: true), ) : _notifications.isEmpty && !_isLoading ? const _EmptyState() : RefreshIndicator( onRefresh: () => _loadNotifications(refresh: true), child: ListView.builder( itemCount: _notifications.length + (_hasMore ? 1 : 0), padding: const EdgeInsets.symmetric(vertical: 8), itemBuilder: (context, index) { if (index == _notifications.length) { if (!_isLoading) { _loadNotifications(); } return const Padding( padding: EdgeInsets.all(16.0), child: Center( child: CircularProgressIndicator(), ), ); } final notification = _notifications[index]; if (_activeTabIndex == 0) { return Dismissible( key: Key('notif_${notification.id}'), direction: DismissDirection.endToStart, onDismissed: (_) => _archiveNotification(notification), background: Container( alignment: Alignment.centerRight, padding: const EdgeInsets.only(right: 20), color: AppTheme.royalPurple.withOpacity(0.8), child: const Row( mainAxisAlignment: MainAxisAlignment.end, children: [ Text( 'Archive', style: TextStyle( color: Colors.white, fontWeight: FontWeight.bold, ), ), SizedBox(width: 8), Icon(Icons.archive, color: Colors.white), ], ), ), child: _NotificationItem( notification: notification, onTap: notification.type == NotificationType.follow_request ? null : () => _handleNotificationTap(notification), onApprove: notification.type == NotificationType.follow_request ? () => _approveFollowRequest(notification) : null, onReject: notification.type == NotificationType.follow_request ? () => _rejectFollowRequest(notification) : null, ), ); } else { return _NotificationItem( notification: notification, onTap: notification.type == NotificationType.follow_request ? null : () => _handleNotificationTap(notification), ); } }, ), ), ), ); } } /// Individual notification item widget class _NotificationItem extends StatelessWidget { final AppNotification notification; final VoidCallback? onTap; final VoidCallback? onApprove; final VoidCallback? onReject; const _NotificationItem({ required this.notification, this.onTap, this.onApprove, this.onReject, }); @override Widget build(BuildContext context) { if (notification.type == NotificationType.follow_request) { return _FollowRequestItem( notification: notification, onApprove: onApprove, onReject: onReject, ); } return InkWell( onTap: onTap, child: Container( padding: const EdgeInsets.all(AppTheme.spacingMd), decoration: BoxDecoration( color: notification.isRead ? Colors.transparent : AppTheme.royalPurple.withOpacity(0.05), border: Border( bottom: BorderSide( color: AppTheme.egyptianBlue.withOpacity(0.15), width: 1, ), ), ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildAvatar(), const SizedBox(width: AppTheme.spacingMd), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: Text( notification.message, style: AppTheme.textTheme.bodyMedium?.copyWith( fontWeight: notification.isRead ? FontWeight.w400 : FontWeight.w600, ), ), ), if (!notification.isRead) Container( width: 8, height: 8, decoration: BoxDecoration( color: AppTheme.royalPurple, shape: BoxShape.circle, ), ), ], ), const SizedBox(height: AppTheme.spacingXs), if (notification.postBody != null) ...[ Text( notification.postBody!, style: AppTheme.textTheme.labelSmall?.copyWith( color: AppTheme.navyText.withOpacity(0.6), ), maxLines: 2, overflow: TextOverflow.ellipsis, ), const SizedBox(height: AppTheme.spacingXs), ], Text( timeago.format(notification.createdAt, locale: 'en_short'), style: AppTheme.textTheme.labelSmall?.copyWith( color: AppTheme.egyptianBlue.withOpacity(0.7), ), ), ], ), ), ], ), ), ); } Widget _buildAvatar() { IconData iconData; Color iconColor; switch (notification.type) { case NotificationType.like: iconData = Icons.favorite; iconColor = AppTheme.brightNavy; break; case NotificationType.reply: iconData = Icons.subdirectory_arrow_right; iconColor = AppTheme.royalPurple; break; case NotificationType.follow: iconData = Icons.person_add; iconColor = AppTheme.ksuPurple; break; case NotificationType.follow_accepted: iconData = Icons.check_circle; iconColor = AppTheme.brightNavy; break; case NotificationType.comment: iconData = Icons.comment; iconColor = AppTheme.egyptianBlue; break; case NotificationType.mention: iconData = Icons.alternate_email; iconColor = AppTheme.brightNavy; break; case NotificationType.follow_request: iconData = Icons.person_add; iconColor = AppTheme.ksuPurple; break; case NotificationType.message: iconData = Icons.message; iconColor = AppTheme.egyptianBlue; break; case NotificationType.save: iconData = Icons.bookmark; iconColor = AppTheme.ksuPurple; break; default: iconData = Icons.notifications; iconColor = AppTheme.egyptianBlue; break; } return Container( width: 40, height: 40, decoration: BoxDecoration( color: iconColor.withOpacity(0.1), shape: BoxShape.circle, border: Border.all( color: iconColor.withOpacity(0.3), width: 1, ), ), child: Icon( iconData, color: iconColor, size: 20, ), ); } } class _FollowRequestItem extends StatelessWidget { final AppNotification notification; final VoidCallback? onApprove; final VoidCallback? onReject; const _FollowRequestItem({ required this.notification, this.onApprove, this.onReject, }); @override Widget build(BuildContext context) { final actor = notification.actor; final avatarUrl = actor?.avatarUrl; final displayName = actor?.displayName ?? 'Someone'; final handle = actor?.handle; return Container( padding: const EdgeInsets.all(AppTheme.spacingMd), decoration: BoxDecoration( color: notification.isRead ? Colors.transparent : AppTheme.royalPurple.withOpacity(0.05), border: Border( bottom: BorderSide( color: AppTheme.egyptianBlue.withOpacity(0.15), width: 1, ), ), ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ CircleAvatar( radius: 20, child: avatarUrl != null && avatarUrl.isNotEmpty ? ClipOval( child: SizedBox( width: 40, height: 40, child: SignedMediaImage( url: avatarUrl, width: 40, height: 40, fit: BoxFit.cover, ), ), ) : Text( displayName.isNotEmpty ? displayName.substring(0, 1).toUpperCase() : '?', style: AppTheme.textTheme.labelMedium, ), ), const SizedBox(width: AppTheme.spacingMd), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( displayName, style: AppTheme.textTheme.bodyMedium?.copyWith( fontWeight: notification.isRead ? FontWeight.w400 : FontWeight.w600, ), ), if (handle != null && handle.isNotEmpty) ...[ const SizedBox(height: 2), Text( '@$handle', style: AppTheme.textTheme.labelSmall?.copyWith( color: AppTheme.navyText.withOpacity(0.7), ), ), ], const SizedBox(height: AppTheme.spacingXs), Text( 'requested to follow you', style: AppTheme.textTheme.bodySmall?.copyWith( color: AppTheme.navyText.withOpacity(0.8), ), ), const SizedBox(height: AppTheme.spacingSm), Row( children: [ TextButton( onPressed: onReject, child: const Text('Delete'), ), const SizedBox(width: AppTheme.spacingSm), ElevatedButton( onPressed: onApprove, style: ElevatedButton.styleFrom( backgroundColor: AppTheme.royalPurple, foregroundColor: AppTheme.white, ), child: const Text('Confirm'), ), const Spacer(), Text( timeago.format(notification.createdAt, locale: 'en_short'), style: AppTheme.textTheme.labelSmall?.copyWith( color: AppTheme.egyptianBlue.withOpacity(0.7), ), ), ], ), ], ), ), ], ), ); } } class _ErrorState extends StatelessWidget { final String message; final VoidCallback onRetry; const _ErrorState({ required this.message, required this.onRetry, }); @override Widget build(BuildContext context) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text( message, style: AppTheme.bodyMedium.copyWith( color: AppTheme.error, ), textAlign: TextAlign.center, ), const SizedBox(height: AppTheme.spacingMd), TextButton( onPressed: onRetry, child: const Text('Retry'), ), ], ), ); } } class _EmptyState extends StatelessWidget { const _EmptyState(); @override Widget build(BuildContext context) { return Center( child: Padding( padding: const EdgeInsets.all(AppTheme.spacingLg * 2), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( Icons.notifications_none, size: 80, color: AppTheme.egyptianBlue.withOpacity(0.3), ), const SizedBox(height: AppTheme.spacingLg), Text( 'No notifications yet', style: AppTheme.headlineSmall, textAlign: TextAlign.center, ), const SizedBox(height: AppTheme.spacingMd), Text( "You'll see notifications here when someone appreciates your posts, chains them, or follows you.", style: AppTheme.textTheme.bodyMedium?.copyWith( color: AppTheme.navyText.withOpacity(0.7), ), textAlign: TextAlign.center, ), ], ), ), ); } }