Announcements and IT Job Checklist
This commit is contained in:
@@ -0,0 +1,258 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../models/announcement_comment.dart';
|
||||
import '../../providers/announcements_provider.dart';
|
||||
import '../../providers/profile_provider.dart';
|
||||
import '../../utils/app_time.dart';
|
||||
import '../../widgets/profile_avatar.dart';
|
||||
|
||||
/// Inline, collapsible comments section for an announcement card.
|
||||
class AnnouncementCommentsSection extends ConsumerStatefulWidget {
|
||||
const AnnouncementCommentsSection({
|
||||
super.key,
|
||||
required this.announcementId,
|
||||
});
|
||||
|
||||
final String announcementId;
|
||||
|
||||
@override
|
||||
ConsumerState<AnnouncementCommentsSection> createState() =>
|
||||
_AnnouncementCommentsSectionState();
|
||||
}
|
||||
|
||||
class _AnnouncementCommentsSectionState
|
||||
extends ConsumerState<AnnouncementCommentsSection> {
|
||||
final _controller = TextEditingController();
|
||||
bool _sending = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
final text = _controller.text.trim();
|
||||
if (text.isEmpty || _sending) return;
|
||||
|
||||
setState(() => _sending = true);
|
||||
try {
|
||||
await ref.read(announcementsControllerProvider).addComment(
|
||||
announcementId: widget.announcementId,
|
||||
body: text,
|
||||
);
|
||||
_controller.clear();
|
||||
} on AnnouncementNotificationException {
|
||||
// Comment was posted; only push notification delivery failed.
|
||||
_controller.clear();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Comment posted, but notifications may not have been sent.'),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Failed to post comment: $e')),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _sending = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final commentsAsync =
|
||||
ref.watch(announcementCommentsProvider(widget.announcementId));
|
||||
final profiles = ref.watch(profilesProvider).valueOrNull ?? [];
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final tt = Theme.of(context).textTheme;
|
||||
final currentUserId = ref.watch(currentUserIdProvider);
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Divider(height: 1, color: cs.outlineVariant.withValues(alpha: 0.5)),
|
||||
commentsAsync.when(
|
||||
loading: () => const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
|
||||
),
|
||||
error: (e, _) => Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text('Error loading comments',
|
||||
style: tt.bodySmall?.copyWith(color: cs.error)),
|
||||
),
|
||||
data: (comments) => comments.isEmpty
|
||||
? Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16, vertical: 12),
|
||||
child: Text(
|
||||
'No comments yet',
|
||||
style: tt.bodySmall
|
||||
?.copyWith(color: cs.onSurfaceVariant),
|
||||
),
|
||||
)
|
||||
: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
itemCount: comments.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final comment = comments[index];
|
||||
return _CommentTile(
|
||||
comment: comment,
|
||||
profiles: profiles,
|
||||
currentUserId: currentUserId,
|
||||
onDelete: () async {
|
||||
await ref
|
||||
.read(announcementsControllerProvider)
|
||||
.deleteComment(comment.id);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
// Input row
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
maxLines: null,
|
||||
minLines: 1,
|
||||
keyboardType: TextInputType.multiline,
|
||||
textInputAction: TextInputAction.newline,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Write a comment...',
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 10),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: cs.outlineVariant),
|
||||
),
|
||||
),
|
||||
style: tt.bodyMedium,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
onPressed: _sending ? null : _submit,
|
||||
icon: _sending
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child:
|
||||
CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Icon(Icons.send, color: cs.primary),
|
||||
tooltip: 'Send',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CommentTile extends StatelessWidget {
|
||||
const _CommentTile({
|
||||
required this.comment,
|
||||
required this.profiles,
|
||||
required this.currentUserId,
|
||||
required this.onDelete,
|
||||
});
|
||||
|
||||
final AnnouncementComment comment;
|
||||
final List profiles;
|
||||
final String? currentUserId;
|
||||
final VoidCallback onDelete;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final tt = Theme.of(context).textTheme;
|
||||
|
||||
// Resolve author name
|
||||
String authorName = 'Unknown';
|
||||
String? avatarUrl;
|
||||
for (final p in profiles) {
|
||||
if (p.id == comment.authorId) {
|
||||
authorName = p.fullName;
|
||||
avatarUrl = p.avatarUrl;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
final isOwn = comment.authorId == currentUserId;
|
||||
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ProfileAvatar(fullName: authorName, avatarUrl: avatarUrl, radius: 14),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
authorName,
|
||||
style: tt.labelMedium
|
||||
?.copyWith(fontWeight: FontWeight.w600),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
_relativeTime(comment.createdAt),
|
||||
style: tt.labelSmall
|
||||
?.copyWith(color: cs.onSurfaceVariant),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(comment.body, style: tt.bodySmall),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isOwn)
|
||||
IconButton(
|
||||
icon: Icon(Icons.close, size: 16, color: cs.onSurfaceVariant),
|
||||
onPressed: onDelete,
|
||||
tooltip: 'Delete',
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:skeletonizer/skeletonizer.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 '../../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: Skeletonizer(
|
||||
enabled: showSkeleton,
|
||||
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 && 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) {
|
||||
if (showSkeleton) {
|
||||
// Placeholder card for shimmer
|
||||
return _buildPlaceholderCard(context);
|
||||
}
|
||||
final announcement = items[index];
|
||||
return _AnnouncementCard(
|
||||
announcement: announcement,
|
||||
profiles: profiles,
|
||||
currentUserId: currentUserId,
|
||||
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) {
|
||||
await ref
|
||||
.read(announcementsControllerProvider)
|
||||
.deleteAnnouncement(announcement.id);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
childCount: showSkeleton ? 5 : items.length,
|
||||
),
|
||||
),
|
||||
// Bottom padding so FAB doesn't cover last card
|
||||
const SliverPadding(padding: EdgeInsets.only(bottom: 80)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPlaceholderCard(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 6),
|
||||
child: M3Card.elevated(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const CircleAvatar(radius: 18),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 120,
|
||||
height: 14,
|
||||
color: Colors.grey,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
width: 60,
|
||||
height: 10,
|
||||
color: Colors.grey,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 200),
|
||||
child: Container(
|
||||
width: double.infinity, height: 16, color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: double.infinity, height: 12, color: Colors.grey),
|
||||
const SizedBox(height: 4),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 240),
|
||||
child: Container(
|
||||
width: double.infinity, height: 12, color: Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AnnouncementCard extends ConsumerWidget {
|
||||
const _AnnouncementCard({
|
||||
required this.announcement,
|
||||
required this.profiles,
|
||||
required this.currentUserId,
|
||||
required this.canCreate,
|
||||
required this.isExpanded,
|
||||
required this.onToggleComments,
|
||||
required this.onEdit,
|
||||
required this.onDelete,
|
||||
});
|
||||
|
||||
final Announcement announcement;
|
||||
final List profiles;
|
||||
final String? currentUserId;
|
||||
final bool canCreate;
|
||||
final bool isExpanded;
|
||||
final VoidCallback onToggleComments;
|
||||
final VoidCallback onEdit;
|
||||
final VoidCallback onDelete;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
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) {
|
||||
authorName = p.fullName;
|
||||
avatarUrl = p.avatarUrl;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
final isOwner = announcement.authorId == currentUserId;
|
||||
|
||||
// Comment count
|
||||
final commentsAsync =
|
||||
ref.watch(announcementCommentsProvider(announcement.id));
|
||||
final commentCount = commentsAsync.valueOrNull?.length ?? 0;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: M3Card.elevated(
|
||||
child: Column(
|
||||
// mainAxisSize.min prevents the Column from trying to fill infinite
|
||||
// height when rendered inside a SliverList (unbounded vertical axis).
|
||||
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),
|
||||
Text(
|
||||
_relativeTime(announcement.createdAt),
|
||||
style: tt.labelSmall
|
||||
?.copyWith(color: cs.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isOwner)
|
||||
PopupMenuButton<String>(
|
||||
onSelected: (value) {
|
||||
switch (value) {
|
||||
case 'edit':
|
||||
onEdit();
|
||||
case 'delete':
|
||||
onDelete();
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
const PopupMenuItem(
|
||||
value: 'edit', child: Text('Edit')),
|
||||
const PopupMenuItem(
|
||||
value: 'delete', child: Text('Delete')),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Title + Body
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
child: Text(
|
||||
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),
|
||||
),
|
||||
// Visible roles chips
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 10, 16, 0),
|
||||
child: Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 4,
|
||||
children: announcement.visibleRoles.map((role) {
|
||||
return Chip(
|
||||
label: Text(
|
||||
_roleLabel(role),
|
||||
style: tt.labelSmall,
|
||||
),
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
// Comment toggle row
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Comments section
|
||||
if (isExpanded)
|
||||
AnnouncementCommentsSection(
|
||||
announcementId: announcement.id),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../models/announcement.dart';
|
||||
import '../../providers/announcements_provider.dart';
|
||||
import '../../theme/m3_motion.dart';
|
||||
import '../../utils/snackbar.dart';
|
||||
import '../../widgets/app_breakpoints.dart';
|
||||
|
||||
/// All user roles available for announcement visibility.
|
||||
const _allRoles = ['admin', 'dispatcher', 'programmer', 'it_staff', 'standard'];
|
||||
|
||||
/// Default visible roles (standard is excluded by default).
|
||||
const _defaultVisibleRoles = ['admin', 'dispatcher', 'programmer', 'it_staff'];
|
||||
|
||||
/// Human-readable labels for each role.
|
||||
const _roleLabels = {
|
||||
'admin': 'Admin',
|
||||
'dispatcher': 'Dispatcher',
|
||||
'programmer': 'Programmer',
|
||||
'it_staff': 'IT Staff',
|
||||
'standard': 'Standard',
|
||||
};
|
||||
|
||||
/// Shows the create/edit announcement dialog.
|
||||
///
|
||||
/// On mobile, uses a full-screen bottom sheet; on desktop, a centered dialog.
|
||||
Future<void> showCreateAnnouncementDialog(
|
||||
BuildContext context, {
|
||||
Announcement? editing,
|
||||
}) async {
|
||||
final width = MediaQuery.sizeOf(context).width;
|
||||
if (width < AppBreakpoints.tablet) {
|
||||
await m3ShowBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (ctx) => _CreateAnnouncementContent(editing: editing),
|
||||
);
|
||||
} else {
|
||||
await m3ShowDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) => Dialog(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 560),
|
||||
child: _CreateAnnouncementContent(editing: editing),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CreateAnnouncementContent extends ConsumerStatefulWidget {
|
||||
const _CreateAnnouncementContent({this.editing});
|
||||
|
||||
final Announcement? editing;
|
||||
|
||||
@override
|
||||
ConsumerState<_CreateAnnouncementContent> createState() =>
|
||||
_CreateAnnouncementContentState();
|
||||
}
|
||||
|
||||
class _CreateAnnouncementContentState
|
||||
extends ConsumerState<_CreateAnnouncementContent> {
|
||||
late final TextEditingController _titleCtrl;
|
||||
late final TextEditingController _bodyCtrl;
|
||||
late Set<String> _selectedRoles;
|
||||
bool _isTemplate = false;
|
||||
bool _submitting = false;
|
||||
|
||||
// Template selection
|
||||
String? _selectedTemplateId;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final source = widget.editing;
|
||||
_titleCtrl = TextEditingController(text: source?.title ?? '');
|
||||
_bodyCtrl = TextEditingController(text: source?.body ?? '');
|
||||
_selectedRoles = source != null
|
||||
? Set<String>.from(source.visibleRoles)
|
||||
: Set<String>.from(_defaultVisibleRoles);
|
||||
_isTemplate = widget.editing?.isTemplate ?? false;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_titleCtrl.dispose();
|
||||
_bodyCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool get _canSubmit {
|
||||
final hasContent =
|
||||
_titleCtrl.text.trim().isNotEmpty && _bodyCtrl.text.trim().isNotEmpty;
|
||||
final hasRoles = _selectedRoles.isNotEmpty;
|
||||
return hasContent && hasRoles && !_submitting;
|
||||
}
|
||||
|
||||
void _applyTemplate(Announcement template) {
|
||||
setState(() {
|
||||
_selectedTemplateId = template.id;
|
||||
_titleCtrl.text = template.title;
|
||||
_bodyCtrl.text = template.body;
|
||||
_selectedRoles = Set<String>.from(template.visibleRoles);
|
||||
});
|
||||
}
|
||||
|
||||
void _clearTemplate() {
|
||||
setState(() {
|
||||
_selectedTemplateId = null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (!_canSubmit) return;
|
||||
setState(() => _submitting = true);
|
||||
try {
|
||||
final ctrl = ref.read(announcementsControllerProvider);
|
||||
if (widget.editing != null) {
|
||||
await ctrl.updateAnnouncement(
|
||||
id: widget.editing!.id,
|
||||
title: _titleCtrl.text.trim(),
|
||||
body: _bodyCtrl.text.trim(),
|
||||
visibleRoles: _selectedRoles.toList(),
|
||||
isTemplate: _isTemplate,
|
||||
);
|
||||
} else {
|
||||
await ctrl.createAnnouncement(
|
||||
title: _titleCtrl.text.trim(),
|
||||
body: _bodyCtrl.text.trim(),
|
||||
visibleRoles: _selectedRoles.toList(),
|
||||
isTemplate: _isTemplate,
|
||||
templateId: _selectedTemplateId,
|
||||
);
|
||||
}
|
||||
if (mounted) Navigator.of(context).pop();
|
||||
} on AnnouncementNotificationException {
|
||||
// Saved successfully; only push notification delivery failed.
|
||||
if (mounted) {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
Navigator.of(context).pop();
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Posted, but some notifications failed to send.'),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) showErrorSnackBar(context, 'Failed to save: $e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _submitting = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final tt = Theme.of(context).textTheme;
|
||||
final isEditing = widget.editing != null;
|
||||
|
||||
// Get available templates from the stream (filter client-side)
|
||||
final templates = ref
|
||||
.watch(announcementsProvider)
|
||||
.valueOrNull
|
||||
?.where((a) => a.isTemplate)
|
||||
.toList() ?? [];
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Dialog title
|
||||
Text(
|
||||
isEditing ? 'Edit Announcement' : 'New Announcement',
|
||||
style: tt.titleLarge?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Template selector (only for new announcements)
|
||||
if (!isEditing && templates.isNotEmpty) ...[
|
||||
DropdownButtonFormField<String?>(
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Post from template (optional)',
|
||||
border: const OutlineInputBorder(),
|
||||
suffixIcon: _selectedTemplateId != null
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
tooltip: 'Clear template',
|
||||
onPressed: _clearTemplate,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
key: ValueKey(_selectedTemplateId),
|
||||
initialValue: _selectedTemplateId,
|
||||
items: [
|
||||
const DropdownMenuItem<String?>(
|
||||
value: null,
|
||||
child: Text('No template'),
|
||||
),
|
||||
...templates.map(
|
||||
(t) => DropdownMenuItem<String?>(
|
||||
value: t.id,
|
||||
child: Text(
|
||||
t.title.isEmpty ? '(Untitled)' : t.title,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
onChanged: (id) {
|
||||
if (id == null) {
|
||||
_clearTemplate();
|
||||
} else {
|
||||
final tmpl = templates.firstWhere((t) => t.id == id);
|
||||
_applyTemplate(tmpl);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
// Title field
|
||||
TextField(
|
||||
controller: _titleCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Title',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
textInputAction: TextInputAction.next,
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Body field
|
||||
TextField(
|
||||
controller: _bodyCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Body',
|
||||
border: OutlineInputBorder(),
|
||||
alignLabelWithHint: true,
|
||||
),
|
||||
maxLines: 6,
|
||||
minLines: 3,
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Role visibility
|
||||
Text('Visible to:', style: tt.labelLarge),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 6,
|
||||
children: _allRoles.map((role) {
|
||||
final selected = _selectedRoles.contains(role);
|
||||
return FilterChip(
|
||||
label: Text(_roleLabels[role] ?? role),
|
||||
selected: selected,
|
||||
onSelected: (val) {
|
||||
setState(() {
|
||||
if (val) {
|
||||
_selectedRoles.add(role);
|
||||
} else {
|
||||
_selectedRoles.remove(role);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Template toggle
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text('Save as template', style: tt.bodyMedium),
|
||||
subtitle: Text(
|
||||
'Templates can be selected when creating new announcements.',
|
||||
style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant),
|
||||
),
|
||||
value: _isTemplate,
|
||||
onChanged: (val) => setState(() => _isTemplate = val),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Action buttons
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
FilledButton(
|
||||
onPressed: _canSubmit ? _submit : null,
|
||||
child: _submitting
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: Colors.white),
|
||||
)
|
||||
: Text(isEditing ? 'Save' : 'Post'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user