diff --git a/lib/providers/announcements_provider.dart b/lib/providers/announcements_provider.dart index 7039d3ec..fab01f56 100644 --- a/lib/providers/announcements_provider.dart +++ b/lib/providers/announcements_provider.dart @@ -207,15 +207,17 @@ class AnnouncementsController { // Notify announcement author + previous commenters try { - // Get the announcement author + // Get the announcement author and title final announcement = await _client .from('announcements') - .select('author_id') + .select('author_id, title') .eq('id', announcementId) .maybeSingle(); if (announcement == null) return; final announcementAuthorId = announcement['author_id'] as String; + final announcementTitle = + announcement['title'] as String? ?? 'an announcement'; // Get all unique commenters on this announcement final comments = await _client @@ -233,13 +235,22 @@ class AnnouncementsController { if (notifyIds.isEmpty) return; + // Fetch commenter's display name for a human-readable push body + final commenterData = await _client + .from('profiles') + .select('full_name') + .eq('id', authorId) + .maybeSingle(); + final commenterName = + commenterData?['full_name'] as String? ?? 'Someone'; + await _notifCtrl.createNotification( userIds: notifyIds, type: 'announcement_comment', actorId: authorId, fields: {'announcement_id': announcementId}, pushTitle: 'New Comment', - pushBody: body.length > 100 ? '${body.substring(0, 100)}...' : body, + pushBody: '$commenterName commented on "$announcementTitle"', pushData: { 'announcement_id': announcementId, 'navigate_to': '/announcements', @@ -251,6 +262,46 @@ class AnnouncementsController { } } + /// Re-sends push notifications for an existing announcement. + /// Intended for use by admins or the announcement author. + Future resendAnnouncementNotification( + Announcement announcement) async { + final actorId = _client.auth.currentUser?.id; + if (actorId == null) return; + if (announcement.visibleRoles.isEmpty) return; + + try { + final profiles = await _client + .from('profiles') + .select('id') + .inFilter('role', announcement.visibleRoles); + final userIds = (profiles as List) + .map((p) => p['id'] as String) + .where((id) => id != actorId) + .toList(); + + if (userIds.isEmpty) return; + + await _notifCtrl.createNotification( + userIds: userIds, + type: 'announcement', + actorId: actorId, + fields: {'announcement_id': announcement.id}, + pushTitle: 'Announcement', + pushBody: announcement.title.length > 100 + ? '${announcement.title.substring(0, 100)}...' + : announcement.title, + pushData: { + 'announcement_id': announcement.id, + 'navigate_to': '/announcements', + }, + ); + } catch (e) { + debugPrint('AnnouncementsController: resend notification error: $e'); + throw AnnouncementNotificationException(e.toString()); + } + } + /// Delete a comment. Future deleteComment(String id) async { await _client.from('announcement_comments').delete().eq('id', id); diff --git a/lib/screens/announcements/announcement_comments_section.dart b/lib/screens/announcements/announcement_comments_section.dart index 074f3bbe..e5d90b84 100644 --- a/lib/screens/announcements/announcement_comments_section.dart +++ b/lib/screens/announcements/announcement_comments_section.dart @@ -5,6 +5,7 @@ import '../../models/announcement_comment.dart'; import '../../providers/announcements_provider.dart'; import '../../providers/profile_provider.dart'; import '../../utils/app_time.dart'; +import '../../utils/snackbar.dart'; import '../../widgets/profile_avatar.dart'; /// Inline, collapsible comments section for an announcement card. @@ -43,6 +44,7 @@ class _AnnouncementCommentsSectionState body: text, ); _controller.clear(); + if (mounted) showSuccessSnackBar(context, 'Comment posted.'); } on AnnouncementNotificationException { // Comment was posted; only push notification delivery failed. _controller.clear(); diff --git a/lib/screens/announcements/announcements_screen.dart b/lib/screens/announcements/announcements_screen.dart index 63c99970..0060fdd0 100644 --- a/lib/screens/announcements/announcements_screen.dart +++ b/lib/screens/announcements/announcements_screen.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:skeletonizer/skeletonizer.dart'; @@ -7,6 +9,7 @@ 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'; @@ -105,9 +108,11 @@ class _AnnouncementsScreenState extends ConsumerState { } 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), @@ -145,10 +150,11 @@ class _AnnouncementsScreenState extends ConsumerState { ], ), ); - if (confirm == true) { + if (confirm == true && mounted) { await ref .read(announcementsControllerProvider) .deleteAnnouncement(announcement.id); + showSuccessSnackBarGlobal('Announcement deleted.'); } }, ); @@ -221,11 +227,13 @@ class _AnnouncementsScreenState extends ConsumerState { } } -class _AnnouncementCard extends ConsumerWidget { +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, @@ -236,6 +244,7 @@ class _AnnouncementCard extends ConsumerWidget { final Announcement announcement; final List profiles; final String? currentUserId; + final String currentUserRole; final bool canCreate; final bool isExpanded; final VoidCallback onToggleComments; @@ -243,28 +252,75 @@ class _AnnouncementCard extends ConsumerWidget { final VoidCallback onDelete; @override - Widget build(BuildContext context, WidgetRef ref) { + 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 profiles) { - if (p.id == announcement.authorId) { + for (final p in widget.profiles) { + if (p.id == widget.announcement.authorId) { authorName = p.fullName; avatarUrl = p.avatarUrl; break; } } - final isOwner = announcement.authorId == currentUserId; + final isOwner = widget.announcement.authorId == widget.currentUserId; // Comment count final commentsAsync = - ref.watch(announcementCommentsProvider(announcement.id)); + 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( @@ -297,7 +353,7 @@ class _AnnouncementCard extends ConsumerWidget { ), const SizedBox(height: 2), Text( - _relativeTime(announcement.createdAt), + _relativeTime(widget.announcement.createdAt), style: tt.labelSmall ?.copyWith(color: cs.onSurfaceVariant), ), @@ -309,9 +365,9 @@ class _AnnouncementCard extends ConsumerWidget { onSelected: (value) { switch (value) { case 'edit': - onEdit(); + widget.onEdit(); case 'delete': - onDelete(); + widget.onDelete(); } }, itemBuilder: (context) => [ @@ -328,13 +384,13 @@ class _AnnouncementCard extends ConsumerWidget { Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), child: Text( - announcement.title, + widget.announcement.title, style: tt.titleMedium?.copyWith(fontWeight: FontWeight.w600), ), ), Padding( padding: const EdgeInsets.fromLTRB(16, 6, 16, 0), - child: Text(announcement.body, style: tt.bodyMedium), + child: Text(widget.announcement.body, style: tt.bodyMedium), ), // Visible roles chips Padding( @@ -342,7 +398,7 @@ class _AnnouncementCard extends ConsumerWidget { child: Wrap( spacing: 6, runSpacing: 4, - children: announcement.visibleRoles.map((role) { + children: widget.announcement.visibleRoles.map((role) { return Chip( label: Text( _roleLabel(role), @@ -355,32 +411,80 @@ class _AnnouncementCard extends ConsumerWidget { }).toList(), ), ), - // Comment toggle row + // Bottom action row: comment toggle + optional resend button Padding( padding: const EdgeInsets.fromLTRB(8, 4, 8, 0), - child: TextButton.icon( - onPressed: onToggleComments, - icon: Icon( - 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, - ), + 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 (isExpanded) + if (widget.isExpanded) AnnouncementCommentsSection( - announcementId: announcement.id), + announcementId: widget.announcement.id), ], ), ), diff --git a/lib/screens/announcements/create_announcement_dialog.dart b/lib/screens/announcements/create_announcement_dialog.dart index ce21f487..2a506b10 100644 --- a/lib/screens/announcements/create_announcement_dialog.dart +++ b/lib/screens/announcements/create_announcement_dialog.dart @@ -134,6 +134,8 @@ class _CreateAnnouncementContentState ); } if (mounted) Navigator.of(context).pop(); + showSuccessSnackBarGlobal( + widget.editing != null ? 'Announcement updated.' : 'Announcement posted.'); } on AnnouncementNotificationException { // Saved successfully; only push notification delivery failed. if (mounted) {