Announcements: Show snackbar on event and enhanced comment notification messages

This commit is contained in:
Marc Rejohn Castillano 2026-03-21 19:28:18 +08:00
parent 3fb6fd5c93
commit beb21e48d0
4 changed files with 195 additions and 36 deletions

View File

@ -207,15 +207,17 @@ class AnnouncementsController {
// Notify announcement author + previous commenters // Notify announcement author + previous commenters
try { try {
// Get the announcement author // Get the announcement author and title
final announcement = await _client final announcement = await _client
.from('announcements') .from('announcements')
.select('author_id') .select('author_id, title')
.eq('id', announcementId) .eq('id', announcementId)
.maybeSingle(); .maybeSingle();
if (announcement == null) return; if (announcement == null) return;
final announcementAuthorId = announcement['author_id'] as String; final announcementAuthorId = announcement['author_id'] as String;
final announcementTitle =
announcement['title'] as String? ?? 'an announcement';
// Get all unique commenters on this announcement // Get all unique commenters on this announcement
final comments = await _client final comments = await _client
@ -233,13 +235,22 @@ class AnnouncementsController {
if (notifyIds.isEmpty) return; 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( await _notifCtrl.createNotification(
userIds: notifyIds, userIds: notifyIds,
type: 'announcement_comment', type: 'announcement_comment',
actorId: authorId, actorId: authorId,
fields: {'announcement_id': announcementId}, fields: {'announcement_id': announcementId},
pushTitle: 'New Comment', pushTitle: 'New Comment',
pushBody: body.length > 100 ? '${body.substring(0, 100)}...' : body, pushBody: '$commenterName commented on "$announcementTitle"',
pushData: { pushData: {
'announcement_id': announcementId, 'announcement_id': announcementId,
'navigate_to': '/announcements', '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<void> 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. /// Delete a comment.
Future<void> deleteComment(String id) async { Future<void> deleteComment(String id) async {
await _client.from('announcement_comments').delete().eq('id', id); await _client.from('announcement_comments').delete().eq('id', id);

View File

@ -5,6 +5,7 @@ import '../../models/announcement_comment.dart';
import '../../providers/announcements_provider.dart'; import '../../providers/announcements_provider.dart';
import '../../providers/profile_provider.dart'; import '../../providers/profile_provider.dart';
import '../../utils/app_time.dart'; import '../../utils/app_time.dart';
import '../../utils/snackbar.dart';
import '../../widgets/profile_avatar.dart'; import '../../widgets/profile_avatar.dart';
/// Inline, collapsible comments section for an announcement card. /// Inline, collapsible comments section for an announcement card.
@ -43,6 +44,7 @@ class _AnnouncementCommentsSectionState
body: text, body: text,
); );
_controller.clear(); _controller.clear();
if (mounted) showSuccessSnackBar(context, 'Comment posted.');
} on AnnouncementNotificationException { } on AnnouncementNotificationException {
// Comment was posted; only push notification delivery failed. // Comment was posted; only push notification delivery failed.
_controller.clear(); _controller.clear();

View File

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:skeletonizer/skeletonizer.dart'; import 'package:skeletonizer/skeletonizer.dart';
@ -7,6 +9,7 @@ import '../../providers/announcements_provider.dart';
import '../../providers/profile_provider.dart'; import '../../providers/profile_provider.dart';
import '../../theme/m3_motion.dart'; import '../../theme/m3_motion.dart';
import '../../utils/app_time.dart'; import '../../utils/app_time.dart';
import '../../utils/snackbar.dart';
import '../../widgets/app_page_header.dart'; import '../../widgets/app_page_header.dart';
import '../../widgets/m3_card.dart'; import '../../widgets/m3_card.dart';
import '../../widgets/profile_avatar.dart'; import '../../widgets/profile_avatar.dart';
@ -105,9 +108,11 @@ class _AnnouncementsScreenState extends ConsumerState<AnnouncementsScreen> {
} }
final announcement = items[index]; final announcement = items[index];
return _AnnouncementCard( return _AnnouncementCard(
key: ValueKey(announcement.id),
announcement: announcement, announcement: announcement,
profiles: profiles, profiles: profiles,
currentUserId: currentUserId, currentUserId: currentUserId,
currentUserRole: role,
canCreate: canCreate, canCreate: canCreate,
isExpanded: isExpanded:
_expandedComments.contains(announcement.id), _expandedComments.contains(announcement.id),
@ -145,10 +150,11 @@ class _AnnouncementsScreenState extends ConsumerState<AnnouncementsScreen> {
], ],
), ),
); );
if (confirm == true) { if (confirm == true && mounted) {
await ref await ref
.read(announcementsControllerProvider) .read(announcementsControllerProvider)
.deleteAnnouncement(announcement.id); .deleteAnnouncement(announcement.id);
showSuccessSnackBarGlobal('Announcement deleted.');
} }
}, },
); );
@ -221,11 +227,13 @@ class _AnnouncementsScreenState extends ConsumerState<AnnouncementsScreen> {
} }
} }
class _AnnouncementCard extends ConsumerWidget { class _AnnouncementCard extends ConsumerStatefulWidget {
const _AnnouncementCard({ const _AnnouncementCard({
super.key,
required this.announcement, required this.announcement,
required this.profiles, required this.profiles,
required this.currentUserId, required this.currentUserId,
required this.currentUserRole,
required this.canCreate, required this.canCreate,
required this.isExpanded, required this.isExpanded,
required this.onToggleComments, required this.onToggleComments,
@ -236,6 +244,7 @@ class _AnnouncementCard extends ConsumerWidget {
final Announcement announcement; final Announcement announcement;
final List profiles; final List profiles;
final String? currentUserId; final String? currentUserId;
final String currentUserRole;
final bool canCreate; final bool canCreate;
final bool isExpanded; final bool isExpanded;
final VoidCallback onToggleComments; final VoidCallback onToggleComments;
@ -243,28 +252,75 @@ class _AnnouncementCard extends ConsumerWidget {
final VoidCallback onDelete; final VoidCallback onDelete;
@override @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<void> _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 cs = Theme.of(context).colorScheme;
final tt = Theme.of(context).textTheme; final tt = Theme.of(context).textTheme;
// Resolve author // Resolve author
String authorName = 'Unknown'; String authorName = 'Unknown';
String? avatarUrl; String? avatarUrl;
for (final p in profiles) { for (final p in widget.profiles) {
if (p.id == announcement.authorId) { if (p.id == widget.announcement.authorId) {
authorName = p.fullName; authorName = p.fullName;
avatarUrl = p.avatarUrl; avatarUrl = p.avatarUrl;
break; break;
} }
} }
final isOwner = announcement.authorId == currentUserId; final isOwner = widget.announcement.authorId == widget.currentUserId;
// Comment count // Comment count
final commentsAsync = final commentsAsync =
ref.watch(announcementCommentsProvider(announcement.id)); ref.watch(announcementCommentsProvider(widget.announcement.id));
final commentCount = commentsAsync.valueOrNull?.length ?? 0; 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( return Padding(
padding: const EdgeInsets.symmetric(vertical: 6), padding: const EdgeInsets.symmetric(vertical: 6),
child: M3Card.elevated( child: M3Card.elevated(
@ -297,7 +353,7 @@ class _AnnouncementCard extends ConsumerWidget {
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
_relativeTime(announcement.createdAt), _relativeTime(widget.announcement.createdAt),
style: tt.labelSmall style: tt.labelSmall
?.copyWith(color: cs.onSurfaceVariant), ?.copyWith(color: cs.onSurfaceVariant),
), ),
@ -309,9 +365,9 @@ class _AnnouncementCard extends ConsumerWidget {
onSelected: (value) { onSelected: (value) {
switch (value) { switch (value) {
case 'edit': case 'edit':
onEdit(); widget.onEdit();
case 'delete': case 'delete':
onDelete(); widget.onDelete();
} }
}, },
itemBuilder: (context) => [ itemBuilder: (context) => [
@ -328,13 +384,13 @@ class _AnnouncementCard extends ConsumerWidget {
Padding( Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: Text( child: Text(
announcement.title, widget.announcement.title,
style: tt.titleMedium?.copyWith(fontWeight: FontWeight.w600), style: tt.titleMedium?.copyWith(fontWeight: FontWeight.w600),
), ),
), ),
Padding( Padding(
padding: const EdgeInsets.fromLTRB(16, 6, 16, 0), 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 // Visible roles chips
Padding( Padding(
@ -342,7 +398,7 @@ class _AnnouncementCard extends ConsumerWidget {
child: Wrap( child: Wrap(
spacing: 6, spacing: 6,
runSpacing: 4, runSpacing: 4,
children: announcement.visibleRoles.map((role) { children: widget.announcement.visibleRoles.map((role) {
return Chip( return Chip(
label: Text( label: Text(
_roleLabel(role), _roleLabel(role),
@ -355,32 +411,80 @@ class _AnnouncementCard extends ConsumerWidget {
}).toList(), }).toList(),
), ),
), ),
// Comment toggle row // Bottom action row: comment toggle + optional resend button
Padding( Padding(
padding: const EdgeInsets.fromLTRB(8, 4, 8, 0), padding: const EdgeInsets.fromLTRB(8, 4, 8, 0),
child: TextButton.icon( child: Row(
onPressed: onToggleComments, children: [
icon: Icon( TextButton.icon(
isExpanded onPressed: widget.onToggleComments,
? Icons.expand_less icon: Icon(
: Icons.comment_outlined, widget.isExpanded
size: 18, ? Icons.expand_less
), : Icons.comment_outlined,
label: Text( size: 18,
commentCount > 0 ),
? '$commentCount comment${commentCount == 1 ? '' : 's'}' label: Text(
: 'Comment', commentCount > 0
), ? '$commentCount comment${commentCount == 1 ? '' : 's'}'
style: TextButton.styleFrom( : 'Comment',
foregroundColor: cs.onSurfaceVariant, ),
textStyle: tt.labelMedium, 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 // Comments section
if (isExpanded) if (widget.isExpanded)
AnnouncementCommentsSection( AnnouncementCommentsSection(
announcementId: announcement.id), announcementId: widget.announcement.id),
], ],
), ),
), ),

View File

@ -134,6 +134,8 @@ class _CreateAnnouncementContentState
); );
} }
if (mounted) Navigator.of(context).pop(); if (mounted) Navigator.of(context).pop();
showSuccessSnackBarGlobal(
widget.editing != null ? 'Announcement updated.' : 'Announcement posted.');
} on AnnouncementNotificationException { } on AnnouncementNotificationException {
// Saved successfully; only push notification delivery failed. // Saved successfully; only push notification delivery failed.
if (mounted) { if (mounted) {