import 'dart:async'; import 'dart:math' as math show min; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.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: 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) SliverList( delegate: SliverChildBuilderDelegate( (context, index) => _buildPlaceholderCard(context), childCount: 5, ), ) else if (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) { final announcement = items[index]; final delay = Duration( milliseconds: math.min(index, 8) * 60); return M3FadeSlideIn( key: ValueKey(announcement.id), delay: delay, child: _AnnouncementCard( 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.'); } }, onBannerSettings: () => showBannerSettingsDialog( context, announcement: announcement, ), ), ); }, childCount: items.length, ), ), const SliverPadding(padding: EdgeInsets.only(bottom: 80)), ], ), ), ); } Widget _buildPlaceholderCard(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(vertical: 6), child: M3Card.elevated( child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ M3ShimmerBox( width: 36, height: 36, borderRadius: BorderRadius.circular(18), ), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ M3ShimmerBox(width: 120, height: 13), const SizedBox(height: 5), M3ShimmerBox(width: 64, height: 10), ], ), ), ], ), const SizedBox(height: 14), M3ShimmerBox(width: 200, height: 15), const SizedBox(height: 8), M3ShimmerBox(height: 12), const SizedBox(height: 5), M3ShimmerBox(width: 240, height: 12), const SizedBox(height: 5), M3ShimmerBox(width: 160, height: 12), ], ), ), ), ); } } // ───────────────────────────────────────────────────────────────────────────── // Announcement card // ───────────────────────────────────────────────────────────────────────────── class _AnnouncementCard extends ConsumerStatefulWidget { const _AnnouncementCard({ 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, required this.onBannerSettings, }); 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; final VoidCallback onBannerSettings; @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 _isOwner => widget.announcement.authorId == widget.currentUserId; bool get _isAdmin => widget.currentUserRole == 'admin'; bool get _canManageBanner => _isOwner || _isAdmin; bool get _canResend => _isOwner || _isAdmin; Future _resendNotification() async { try { await ref .read(announcementsControllerProvider) .resendAnnouncementNotification(widget.announcement); if (mounted) setState(() => _sentAt = DateTime.now()); } on AnnouncementNotificationException { 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; } } // Comment count final commentsAsync = ref.watch(announcementCommentsProvider(widget.announcement.id)); final commentCount = commentsAsync.valueOrNull?.length ?? 0; // Rebuild UI when cooldown is active. if (_inCooldown) { Future.delayed(const Duration(milliseconds: 500), () { if (mounted) setState(() {}); }); } final hasBanner = widget.announcement.bannerEnabled; return Padding( padding: const EdgeInsets.symmetric(vertical: 6), child: M3Card.elevated( child: Column( 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), Row( children: [ Flexible( child: Text( _relativeTime(widget.announcement.createdAt), style: tt.labelSmall ?.copyWith(color: cs.onSurfaceVariant), ), ), if (hasBanner) ...[ const SizedBox(width: 6), Tooltip( message: widget.announcement.isBannerActive ? 'Banner active' : 'Banner (inactive)', child: Icon( Icons.campaign, size: 14, color: widget.announcement.isBannerActive ? cs.primary : cs.onSurfaceVariant, ), ), ], ], ), ], ), ), // Popup menu: owner sees Edit/Delete/Banner; // admin-only sees Delete/Banner if (_isOwner || _canManageBanner) PopupMenuButton( onSelected: (value) { switch (value) { case 'edit': widget.onEdit(); case 'delete': widget.onDelete(); case 'banner': widget.onBannerSettings(); } }, itemBuilder: (context) => [ if (_isOwner) const PopupMenuItem( value: 'edit', child: Text('Edit')), if (_isOwner || _isAdmin) const PopupMenuItem( value: 'delete', child: Text('Delete')), if (_canManageBanner) PopupMenuItem( value: 'banner', child: Row( children: [ Icon(Icons.campaign_outlined, size: 18, color: Theme.of(context) .colorScheme .primary), const SizedBox(width: 8), const Text('Banner Settings'), ], ), ), ], ), ], ), ), // 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 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), ], ), ), ); } } // ───────────────────────────────────────────────────────────────────────────── // Helpers // ───────────────────────────────────────────────────────────────────────────── 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; }