Announcements: Show snackbar on event and enhanced comment notification messages
This commit is contained in:
parent
3fb6fd5c93
commit
beb21e48d0
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user