562 lines
21 KiB
Dart
562 lines
21 KiB
Dart
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<AnnouncementsScreen> createState() =>
|
|
_AnnouncementsScreenState();
|
|
}
|
|
|
|
class _AnnouncementsScreenState extends ConsumerState<AnnouncementsScreen> {
|
|
final Set<String> _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<bool>(
|
|
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<void> _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<String>(
|
|
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;
|
|
}
|
|
|