import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:permission_handler/permission_handler.dart'; import '../../models/notification_item.dart'; import '../../services/notification_service.dart'; import '../../providers/notifications_provider.dart'; import '../../providers/profile_provider.dart'; import '../../providers/tasks_provider.dart'; import '../../providers/tickets_provider.dart'; import '../../widgets/app_page_header.dart'; import '../../widgets/app_state_view.dart'; import '../../widgets/mono_text.dart'; import '../../widgets/responsive_body.dart'; import '../../theme/app_surfaces.dart'; import '../../theme/m3_motion.dart'; class NotificationsScreen extends ConsumerStatefulWidget { const NotificationsScreen({super.key}); @override ConsumerState createState() => _NotificationsScreenState(); } class _NotificationsScreenState extends ConsumerState { bool _showBanner = false; bool _dismissed = false; @override void initState() { super.initState(); _checkChannel(); } Future _checkChannel() async { final muted = await NotificationService.isHighPriorityChannelMuted(); if (!mounted) return; if (muted) setState(() => _showBanner = true); } String _relativeTime(DateTime dt) { final diff = DateTime.now().difference(dt.toLocal()); if (diff.inMinutes < 1) return 'Just now'; if (diff.inMinutes < 60) return '${diff.inMinutes}m ago'; if (diff.inHours < 24) return '${diff.inHours}h ago'; if (diff.inDays == 1) return 'Yesterday'; return '${diff.inDays}d ago'; } String _dateGroup(DateTime dt) { final now = DateTime.now(); final today = DateTime(now.year, now.month, now.day); final yesterday = today.subtract(const Duration(days: 1)); final local = dt.toLocal(); final d = DateTime(local.year, local.month, local.day); if (d == today) return 'Today'; if (d == yesterday) return 'Yesterday'; return 'Earlier'; } Color _iconContainerColor(BuildContext context, String type) { final cs = Theme.of(context).colorScheme; return switch (type) { 'mention' || 'announcement' || 'announcement_comment' => cs.secondaryContainer, 'assignment' || 'created' || 'isr_assigned' => cs.primaryContainer, _ => cs.tertiaryContainer, }; } Color _iconForegroundColor(BuildContext context, String type) { final cs = Theme.of(context).colorScheme; return switch (type) { 'mention' || 'announcement' || 'announcement_comment' => cs.onSecondaryContainer, 'assignment' || 'created' || 'isr_assigned' => cs.onPrimaryContainer, _ => cs.onTertiaryContainer, }; } String _notificationTitle(String type, String actorName) { return switch (type) { 'assignment' => '$actorName assigned you', 'created' => '$actorName created a new item', 'swap_request' => '$actorName requested a shift swap', 'swap_update' => '$actorName updated a swap request', 'announcement' => '$actorName posted an announcement', 'announcement_comment' => '$actorName commented on an announcement', 'it_job_reminder' => 'IT Job submission reminder', 'isr_approved' => 'IT Service Request approved', 'isr_status_changed' => 'IT Service Request status updated', 'isr_assigned' => '$actorName assigned you an IT Service Request', _ => '$actorName mentioned you', }; } IconData _notificationIcon(String type) { return switch (type) { 'assignment' => Icons.assignment_ind_outlined, 'created' => Icons.campaign_outlined, 'swap_request' => Icons.swap_horiz, 'swap_update' => Icons.update, 'announcement' => Icons.campaign, 'announcement_comment' => Icons.comment_outlined, 'it_job_reminder' => Icons.print, 'isr_approved' => Icons.check_circle_outline, 'isr_status_changed' => Icons.sync_alt_outlined, 'isr_assigned' => Icons.engineering_outlined, _ => Icons.alternate_email, }; } Future _handleTap(BuildContext context, NotificationItem item) async { final ticketId = item.ticketId; final taskId = item.taskId; final isrId = item.itServiceRequestId; if (ticketId != null) { await ref .read(notificationsControllerProvider) .markReadForTicket(ticketId); } else if (taskId != null) { await ref.read(notificationsControllerProvider).markReadForTask(taskId); } else if (item.isUnread) { await ref.read(notificationsControllerProvider).markRead(item.id); } if (!context.mounted) return; if (item.announcementId != null) { context.go('/announcements'); } else if (taskId != null) { context.go('/tasks/$taskId'); } else if (ticketId != null) { context.go('/tickets/$ticketId'); } else if (isrId != null) { context.go('/it-service-requests/$isrId'); } } @override Widget build(BuildContext context) { final notificationsAsync = ref.watch(notificationsProvider); final profilesAsync = ref.watch(profilesProvider); final ticketsAsync = ref.watch(ticketsProvider); final tasksAsync = ref.watch(tasksProvider); final profileById = { for (final p in profilesAsync.valueOrNull ?? []) p.id: p, }; final ticketById = { for (final t in ticketsAsync.valueOrNull ?? []) t.id: t, }; final taskById = { for (final t in tasksAsync.valueOrNull ?? []) t.id: t, }; final hasUnread = notificationsAsync.valueOrNull?.any((n) => n.isUnread) ?? false; return ResponsiveBody( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ AppPageHeader( title: 'Notifications', subtitle: 'Updates and mentions across tasks and tickets', actions: hasUnread ? [ IconButton( icon: const Icon(Icons.done_all_outlined), tooltip: 'Mark all as read', onPressed: () => ref.read(notificationsControllerProvider).markAllRead(), ), ] : null, ), if (_showBanner && !_dismissed) Padding( padding: const EdgeInsets.only(bottom: 12), child: MaterialBanner( content: const Text( 'Push notifications are currently silenced. Tap here to fix.', ), actions: [ TextButton( onPressed: openAppSettings, child: const Text('Open settings'), ), TextButton( onPressed: () => setState(() => _dismissed = true), child: const Text('Dismiss'), ), ], ), ), Expanded( child: notificationsAsync.when( data: (items) { if (items.isEmpty) { return const AppEmptyView( icon: Icons.notifications_none_outlined, title: 'No notifications yet', subtitle: "You'll see updates here when something needs your attention.", ); } // Build a flat list interleaving section-header strings and items. // Parallel animIndexes tracks the per-item animation delay index // (-1 for headers which don't animate independently). final entries = []; final animIndexes = []; int ni = 0; String? lastGroup; for (final item in items) { final group = _dateGroup(item.createdAt); if (group != lastGroup) { entries.add(group); animIndexes.add(-1); lastGroup = group; } entries.add(item); animIndexes.add(ni++); } return RefreshIndicator( onRefresh: () async => ref.invalidate(notificationsProvider), child: ListView.builder( padding: const EdgeInsets.only(bottom: 24), itemCount: entries.length, itemBuilder: (context, index) { final entry = entries[index]; if (entry is String) { return _SectionHeader(label: entry); } final item = entry as NotificationItem; final actorName = item.actorId == null ? 'System' : (profileById[item.actorId]?.fullName ?? item.actorId!); return Padding( padding: const EdgeInsets.only(bottom: 8), child: _NotificationCard( item: item, animDelay: Duration( milliseconds: animIndexes[index].clamp(0, 6) * 50, ), title: _notificationTitle(item.type, actorName), icon: _notificationIcon(item.type), iconContainerColor: _iconContainerColor(context, item.type), iconForegroundColor: _iconForegroundColor(context, item.type), taskTitle: item.taskId == null ? null : taskById[item.taskId]?.title, ticketSubject: item.ticketId == null ? null : ticketById[item.ticketId]?.subject, relativeTime: _relativeTime(item.createdAt), onTap: () => _handleTap(context, item), ), ); }, ), ); }, loading: () => const Center(child: CircularProgressIndicator()), error: (error, _) => AppErrorView( error: error, onRetry: () => ref.invalidate(notificationsProvider), ), ), ), ], ), ); } } class _SectionHeader extends StatelessWidget { const _SectionHeader({required this.label}); final String label; @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.fromLTRB(4, 16, 4, 8), child: Text( label, style: Theme.of(context).textTheme.labelMedium?.copyWith( color: Theme.of(context).colorScheme.onSurfaceVariant, fontWeight: FontWeight.w600, letterSpacing: 0.5, ), ), ); } } class _NotificationCard extends StatelessWidget { const _NotificationCard({ required this.item, required this.animDelay, required this.title, required this.icon, required this.iconContainerColor, required this.iconForegroundColor, required this.taskTitle, required this.ticketSubject, required this.relativeTime, required this.onTap, }); final NotificationItem item; final Duration animDelay; final String title; final IconData icon; final Color iconContainerColor; final Color iconForegroundColor; final String? taskTitle; final String? ticketSubject; final String relativeTime; final VoidCallback onTap; String get _subtitle { if (item.announcementId != null) return 'Announcement'; if (item.taskId != null) return taskTitle ?? 'Task'; if (item.itServiceRequestId != null) return 'IT Service Request'; return ticketSubject ?? 'Ticket'; } // Shows a short monospace reference only when no human-readable label exists. String? get _refLabel { if (item.itServiceRequestId != null) { return 'ISR #${item.itServiceRequestId!.substring(0, 8)}'; } if (item.ticketId != null && ticketSubject == null) { return 'Ticket #${item.ticketId!.substring(0, 8)}'; } if (item.taskId != null && taskTitle == null) { return 'Task #${item.taskId!.substring(0, 8)}'; } return null; } @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final tt = Theme.of(context).textTheme; final cardColor = item.isUnread ? cs.primaryContainer.withValues(alpha: 0.28) : null; return M3FadeSlideIn( delay: animDelay, child: M3PressScale( onTap: onTap, child: Card( color: cardColor, shape: AppSurfaces.of(context).compactShape, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Tonal icon container keyed by notification category Container( width: 40, height: 40, decoration: BoxDecoration( color: iconContainerColor, shape: BoxShape.circle, ), child: Icon(icon, size: 20, color: iconForegroundColor), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( title, style: tt.bodyMedium?.copyWith( fontWeight: item.isUnread ? FontWeight.w600 : FontWeight.normal, ), ), const SizedBox(height: 2), Text( _subtitle, style: tt.bodySmall ?.copyWith(color: cs.onSurfaceVariant), maxLines: 1, overflow: TextOverflow.ellipsis, ), if (_refLabel != null) ...[ const SizedBox(height: 2), MonoText(_refLabel!), ], ], ), ), const SizedBox(width: 8), // Timestamp + unread indicator Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( relativeTime, style: tt.labelSmall?.copyWith(color: cs.onSurfaceVariant), ), if (item.isUnread) ...[ const SizedBox(height: 6), Container( width: 8, height: 8, decoration: BoxDecoration( color: cs.primary, shape: BoxShape.circle, ), ), ], ], ), ], ), ), ), ), ); } }