import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:skeletonizer/skeletonizer.dart'; import '../../models/announcement.dart'; import '../../providers/announcements_provider.dart'; import '../../providers/profile_provider.dart'; import '../../theme/m3_motion.dart'; import '../../utils/app_time.dart'; import '../../utils/snackbar.dart'; import '../../widgets/app_page_header.dart'; import '../../widgets/m3_card.dart'; import '../../widgets/profile_avatar.dart'; import '../../widgets/responsive_body.dart'; import 'announcement_comments_section.dart'; import 'create_announcement_dialog.dart'; class AnnouncementsScreen extends ConsumerStatefulWidget { const AnnouncementsScreen({super.key}); @override ConsumerState createState() => _AnnouncementsScreenState(); } class _AnnouncementsScreenState extends ConsumerState { final Set _expandedComments = {}; @override Widget build(BuildContext context) { final announcementsAsync = ref.watch(announcementsProvider); final profiles = ref.watch(profilesProvider).valueOrNull ?? []; final currentProfile = ref.watch(currentProfileProvider).valueOrNull; final currentUserId = ref.watch(currentUserIdProvider); final role = currentProfile?.role ?? 'standard'; final canCreate = const ['admin', 'dispatcher', 'programmer', 'it_staff'] .contains(role); final hasValue = announcementsAsync.hasValue; final hasError = announcementsAsync.hasError; final items = announcementsAsync.valueOrNull ?? []; final showSkeleton = !hasValue && !hasError; return Scaffold( floatingActionButton: canCreate ? M3ExpandedFab( heroTag: 'announcement_fab', onPressed: () => showCreateAnnouncementDialog(context), icon: const Icon(Icons.add), label: const Text('New Announcement'), ) : null, body: ResponsiveBody( child: Skeletonizer( enabled: showSkeleton, child: CustomScrollView( slivers: [ SliverToBoxAdapter( child: AppPageHeader(title: 'Announcements'), ), if (hasError && !hasValue) SliverFillRemaining( child: Center( child: Text( 'Failed to load announcements.', style: Theme.of(context) .textTheme .bodyMedium ?.copyWith( color: Theme.of(context).colorScheme.error), ), ), ) else if (!showSkeleton && items.isEmpty) SliverFillRemaining( child: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.campaign_outlined, size: 64, color: Theme.of(context) .colorScheme .onSurfaceVariant), const SizedBox(height: 12), Text('No announcements yet', style: Theme.of(context) .textTheme .bodyLarge ?.copyWith( color: Theme.of(context) .colorScheme .onSurfaceVariant)), ], ), ), ) else SliverList( delegate: SliverChildBuilderDelegate( (context, index) { if (showSkeleton) { // Placeholder card for shimmer return _buildPlaceholderCard(context); } final announcement = items[index]; return _AnnouncementCard( key: ValueKey(announcement.id), announcement: announcement, profiles: profiles, currentUserId: currentUserId, currentUserRole: role, canCreate: canCreate, isExpanded: _expandedComments.contains(announcement.id), onToggleComments: () { setState(() { if (_expandedComments.contains(announcement.id)) { _expandedComments.remove(announcement.id); } else { _expandedComments.add(announcement.id); } }); }, onEdit: () => showCreateAnnouncementDialog( context, editing: announcement, ), onDelete: () async { final confirm = await showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('Delete Announcement'), content: const Text( 'Are you sure you want to delete this announcement?'), actions: [ TextButton( onPressed: () => Navigator.of(ctx).pop(false), child: const Text('Cancel'), ), FilledButton( onPressed: () => Navigator.of(ctx).pop(true), child: const Text('Delete'), ), ], ), ); if (confirm == true && mounted) { await ref .read(announcementsControllerProvider) .deleteAnnouncement(announcement.id); showSuccessSnackBarGlobal('Announcement deleted.'); } }, ); }, childCount: showSkeleton ? 5 : items.length, ), ), // Bottom padding so FAB doesn't cover last card const SliverPadding(padding: EdgeInsets.only(bottom: 80)), ], ), ), ), ); } Widget _buildPlaceholderCard(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 6), child: M3Card.elevated( child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ const CircleAvatar(radius: 18), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( width: 120, height: 14, color: Colors.grey, ), const SizedBox(height: 4), Container( width: 60, height: 10, color: Colors.grey, ), ], ), ), ], ), const SizedBox(height: 12), ConstrainedBox( constraints: const BoxConstraints(maxWidth: 200), child: Container( width: double.infinity, height: 16, color: Colors.grey), ), const SizedBox(height: 8), Container( width: double.infinity, height: 12, color: Colors.grey), const SizedBox(height: 4), ConstrainedBox( constraints: const BoxConstraints(maxWidth: 240), child: Container( width: double.infinity, height: 12, color: Colors.grey), ), ], ), ), ), ); } } class _AnnouncementCard extends ConsumerStatefulWidget { const _AnnouncementCard({ super.key, required this.announcement, required this.profiles, required this.currentUserId, required this.currentUserRole, required this.canCreate, required this.isExpanded, required this.onToggleComments, required this.onEdit, required this.onDelete, }); final Announcement announcement; final List profiles; final String? currentUserId; final String currentUserRole; final bool canCreate; final bool isExpanded; final VoidCallback onToggleComments; final VoidCallback onEdit; final VoidCallback onDelete; @override ConsumerState<_AnnouncementCard> createState() => _AnnouncementCardState(); } class _AnnouncementCardState extends ConsumerState<_AnnouncementCard> { static const _cooldownSeconds = 60; DateTime? _sentAt; @override void dispose() { super.dispose(); } int get _secondsRemaining { if (_sentAt == null) return 0; final elapsed = DateTime.now().difference(_sentAt!).inSeconds; return (_cooldownSeconds - elapsed).clamp(0, _cooldownSeconds); } bool get _inCooldown => _secondsRemaining > 0; bool get _canResend => widget.announcement.authorId == widget.currentUserId || widget.currentUserRole == 'admin'; Future _resendNotification() async { try { await ref .read(announcementsControllerProvider) .resendAnnouncementNotification(widget.announcement); if (mounted) setState(() => _sentAt = DateTime.now()); } on AnnouncementNotificationException { // Partial failure — start cooldown to prevent spam if (mounted) setState(() => _sentAt = DateTime.now()); } catch (e) { if (mounted) showErrorSnackBar(context, 'Failed to send: $e'); } } @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final tt = Theme.of(context).textTheme; // Resolve author String authorName = 'Unknown'; String? avatarUrl; for (final p in widget.profiles) { if (p.id == widget.announcement.authorId) { authorName = p.fullName; avatarUrl = p.avatarUrl; break; } } final isOwner = widget.announcement.authorId == widget.currentUserId; // Comment count final commentsAsync = ref.watch(announcementCommentsProvider(widget.announcement.id)); final commentCount = commentsAsync.valueOrNull?.length ?? 0; // Rebuild UI when cooldown is active to update the countdown display. // This timer triggers rebuilds every 500ms while _inCooldown is true. if (_inCooldown) { Future.delayed(const Duration(milliseconds: 500), () { if (mounted) setState(() {}); }); } return Padding( padding: const EdgeInsets.symmetric(vertical: 6), child: M3Card.elevated( child: Column( // mainAxisSize.min prevents the Column from trying to fill infinite // height when rendered inside a SliverList (unbounded vertical axis). mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 16, 8, 0), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ ProfileAvatar( fullName: authorName, avatarUrl: avatarUrl, radius: 20, ), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( authorName, style: tt.titleSmall ?.copyWith(fontWeight: FontWeight.w600), overflow: TextOverflow.ellipsis, ), const SizedBox(height: 2), Text( _relativeTime(widget.announcement.createdAt), style: tt.labelSmall ?.copyWith(color: cs.onSurfaceVariant), ), ], ), ), if (isOwner) PopupMenuButton( onSelected: (value) { switch (value) { case 'edit': widget.onEdit(); case 'delete': widget.onDelete(); } }, itemBuilder: (context) => [ const PopupMenuItem( value: 'edit', child: Text('Edit')), const PopupMenuItem( value: 'delete', child: Text('Delete')), ], ), ], ), ), // Title + Body Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), child: Text( widget.announcement.title, style: tt.titleMedium?.copyWith(fontWeight: FontWeight.w600), ), ), Padding( padding: const EdgeInsets.fromLTRB(16, 6, 16, 0), child: Text(widget.announcement.body, style: tt.bodyMedium), ), // Visible roles chips Padding( padding: const EdgeInsets.fromLTRB(16, 10, 16, 0), child: Wrap( spacing: 6, runSpacing: 4, children: widget.announcement.visibleRoles.map((role) { return Chip( label: Text( _roleLabel(role), style: tt.labelSmall, ), visualDensity: VisualDensity.compact, padding: EdgeInsets.zero, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, ); }).toList(), ), ), // Bottom action row: comment toggle + optional resend button Padding( padding: const EdgeInsets.fromLTRB(8, 4, 8, 0), child: Row( children: [ TextButton.icon( onPressed: widget.onToggleComments, icon: Icon( widget.isExpanded ? Icons.expand_less : Icons.comment_outlined, size: 18, ), label: Text( commentCount > 0 ? '$commentCount comment${commentCount == 1 ? '' : 's'}' : 'Comment', ), style: TextButton.styleFrom( foregroundColor: cs.onSurfaceVariant, textStyle: tt.labelMedium, ), ), const Spacer(), if (_canResend) SizedBox( width: 40, height: 40, child: _inCooldown ? Tooltip( message: '$_secondsRemaining s remaining', child: Stack( alignment: Alignment.center, children: [ SizedBox( width: 28, height: 28, child: CircularProgressIndicator( value: _secondsRemaining / _cooldownSeconds, strokeWidth: 2.5, color: cs.primary, backgroundColor: cs.primary .withValues(alpha: 0.15), ), ), Text( '$_secondsRemaining', style: tt.labelSmall?.copyWith( fontSize: 9, color: cs.primary, ), ), ], ), ) : IconButton( tooltip: 'Resend notifications', padding: EdgeInsets.zero, onPressed: _resendNotification, icon: Icon( Icons.notifications_active_outlined, color: cs.primary, size: 20, ), ), ), ], ), ), // Comments section if (widget.isExpanded) AnnouncementCommentsSection( announcementId: widget.announcement.id), ], ), ), ); } } String _relativeTime(DateTime dt) { final now = AppTime.now(); final diff = now.difference(dt); 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 < 7) return '${diff.inDays}d ago'; return AppTime.formatDate(dt); } String _roleLabel(String role) { const labels = { 'admin': 'Admin', 'dispatcher': 'Dispatcher', 'programmer': 'Programmer', 'it_staff': 'IT Staff', 'standard': 'Standard', }; return labels[role] ?? role; }