Announcements and IT Job Checklist

This commit is contained in:
2026-03-21 18:51:04 +08:00
parent 7d9096963a
commit 3fb6fd5c93
19 changed files with 2367 additions and 37 deletions
@@ -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'),
),
],
),
],
),
);
}
}
@@ -115,9 +115,11 @@ class _NotificationsScreenState extends ConsumerState<NotificationsScreen> {
final taskTitle = item.taskId == null
? 'Task'
: (taskById[item.taskId]?.title ?? item.taskId!);
final subtitle = item.taskId != null
? taskTitle
: ticketSubject;
final subtitle = item.announcementId != null
? 'Announcement'
: item.taskId != null
? taskTitle
: ticketSubject;
final title = _notificationTitle(item.type, actorName);
final icon = _notificationIcon(item.type);
@@ -162,7 +164,9 @@ class _NotificationsScreenState extends ConsumerState<NotificationsScreen> {
.markRead(item.id);
}
if (!context.mounted) return;
if (taskId != null) {
if (item.announcementId != null) {
context.go('/announcements');
} else if (taskId != null) {
context.go('/tasks/$taskId');
} else if (ticketId != null) {
context.go('/tickets/$ticketId');
@@ -195,6 +199,12 @@ class _NotificationsScreenState extends ConsumerState<NotificationsScreen> {
return '$actorName requested a shift swap';
case 'swap_update':
return '$actorName updated a swap request';
case 'announcement':
return '$actorName posted an announcement';
case 'announcement_comment':
return '$actorName commented on an announcement';
case 'it_job_reminder':
return 'IT Job submission reminder';
case 'mention':
default:
return '$actorName mentioned you';
@@ -211,6 +221,12 @@ class _NotificationsScreenState extends ConsumerState<NotificationsScreen> {
return Icons.swap_horiz;
case 'swap_update':
return Icons.update;
case 'announcement':
return Icons.campaign;
case 'announcement_comment':
return Icons.comment_outlined;
case 'it_job_reminder':
return Icons.print;
case 'mention':
default:
return Icons.alternate_email;
+597
View File
@@ -0,0 +1,597 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:skeletonizer/skeletonizer.dart';
import '../../models/task.dart';
import '../../models/task_assignment.dart';
import '../../providers/notifications_provider.dart';
import '../../providers/profile_provider.dart';
import '../../providers/tasks_provider.dart';
import '../../providers/teams_provider.dart';
import '../../utils/app_time.dart';
import '../../utils/snackbar.dart';
import '../../widgets/m3_card.dart';
import '../../widgets/mono_text.dart';
import '../../widgets/profile_avatar.dart';
/// IT Job Checklist tab — visible only to admin/dispatcher.
///
/// Shows all completed tasks with a checkbox for tracking printed IT Job
/// submission status, plus a per-task notification button with 60s cooldown.
class ItJobChecklistTab extends ConsumerStatefulWidget {
const ItJobChecklistTab({super.key});
@override
ConsumerState<ItJobChecklistTab> createState() => _ItJobChecklistTabState();
}
class _ItJobChecklistTabState extends ConsumerState<ItJobChecklistTab> {
String? _selectedTeamId;
String? _statusFilter = 'not_submitted';
final _searchController = TextEditingController();
String _searchQuery = '';
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final tt = Theme.of(context).textTheme;
final tasksAsync = ref.watch(tasksProvider);
final assignmentsAsync = ref.watch(taskAssignmentsProvider);
final profiles = ref.watch(profilesProvider).valueOrNull ?? [];
final teams = ref.watch(teamsProvider).valueOrNull ?? [];
final teamMembers = ref.watch(teamMembersProvider).valueOrNull ?? [];
final allTasks = tasksAsync.valueOrNull ?? [];
final allAssignments = assignmentsAsync.valueOrNull ?? [];
final showSkeleton = !tasksAsync.hasValue && !tasksAsync.hasError;
// All completed tasks
var filtered = allTasks.where((t) => t.status == 'completed').toList();
// Search by task # or subject
if (_searchQuery.isNotEmpty) {
final q = _searchQuery.toLowerCase();
filtered = filtered.where((t) {
return (t.taskNumber?.toLowerCase().contains(q) ?? false) ||
t.title.toLowerCase().contains(q);
}).toList();
}
// Filter by team
if (_selectedTeamId != null) {
final memberIds = teamMembers
.where((m) => m.teamId == _selectedTeamId)
.map((m) => m.userId)
.toSet();
filtered = filtered.where((t) {
final taskAssignees =
allAssignments.where((a) => a.taskId == t.id).map((a) => a.userId);
return taskAssignees.any(memberIds.contains);
}).toList();
}
// Filter by submission status
if (_statusFilter == 'submitted') {
filtered = filtered.where((t) => t.itJobPrinted).toList();
} else if (_statusFilter == 'not_submitted') {
filtered = filtered.where((t) => !t.itJobPrinted).toList();
}
// Sort by task number ascending (format YYYY-MM-NNNNN — lexicographic works)
filtered.sort((a, b) {
final ta = a.taskNumber ?? '';
final tb = b.taskNumber ?? '';
return ta.compareTo(tb);
});
// Stats (always computed over all completed, regardless of filter)
final allCompleted = allTasks.where((t) => t.status == 'completed');
final submitted = allCompleted.where((t) => t.itJobPrinted).length;
final total = allCompleted.length;
// Do NOT wrap the outer Column in Skeletonizer — Skeletonizer's measurement
// pass gives the Column's Expanded child unbounded height constraints,
// causing a RenderFlex bottom-overflow. Skeletonizer is applied only to
// the list itself (see below inside Expanded).
return Column(
children: [
// Stats card
Padding(
padding: const EdgeInsets.fromLTRB(0, 12, 0, 4),
child: M3Card.filled(
color: cs.surfaceContainerHighest,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.print, color: cs.primary, size: 20),
const SizedBox(width: 8),
Text(
'IT Job Submission',
style: tt.titleSmall
?.copyWith(fontWeight: FontWeight.w600),
),
const Spacer(),
Text(
'$submitted / $total submitted',
style: tt.labelLarge?.copyWith(
color: cs.primary,
fontWeight: FontWeight.w700,
),
),
],
),
const SizedBox(height: 8),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: total > 0 ? submitted / total : 0,
minHeight: 6,
backgroundColor: cs.surfaceContainerLow,
),
),
],
),
),
),
),
// Filters
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Wrap(
spacing: 12,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
// Search by task # or subject
SizedBox(
width: 240,
child: TextField(
controller: _searchController,
decoration: InputDecoration(
labelText: 'Search task # or subject',
isDense: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 8),
border: const OutlineInputBorder(),
prefixIcon:
const Icon(Icons.search, size: 18),
suffixIcon: _searchQuery.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear, size: 16),
onPressed: () {
_searchController.clear();
setState(() => _searchQuery = '');
},
)
: null,
),
onChanged: (v) =>
setState(() => _searchQuery = v.trim()),
),
),
// Team filter
SizedBox(
width: 180,
child: DropdownButtonFormField<String>(
key: ValueKey(_selectedTeamId),
initialValue: _selectedTeamId,
decoration: const InputDecoration(
labelText: 'Team',
isDense: true,
contentPadding:
EdgeInsets.symmetric(horizontal: 12, vertical: 8),
border: OutlineInputBorder(),
),
isExpanded: true,
items: [
const DropdownMenuItem<String>(
value: null,
child: Text('All Teams'),
),
...teams.map((t) => DropdownMenuItem<String>(
value: t.id,
child: Text(t.name,
overflow: TextOverflow.ellipsis),
)),
],
onChanged: (v) => setState(() => _selectedTeamId = v),
),
),
// Status filter
SizedBox(
width: 180,
child: DropdownButtonFormField<String>(
key: ValueKey(_statusFilter),
initialValue: _statusFilter,
decoration: const InputDecoration(
labelText: 'Status',
isDense: true,
contentPadding:
EdgeInsets.symmetric(horizontal: 12, vertical: 8),
border: OutlineInputBorder(),
),
isExpanded: true,
items: const [
DropdownMenuItem<String>(
value: null, child: Text('All')),
DropdownMenuItem(
value: 'submitted', child: Text('Submitted')),
DropdownMenuItem(
value: 'not_submitted',
child: Text('Not Submitted')),
],
onChanged: (v) => setState(() => _statusFilter = v),
),
),
// Clear button
if (_selectedTeamId != null ||
_statusFilter != 'not_submitted' ||
_searchQuery.isNotEmpty)
TextButton.icon(
onPressed: () {
_searchController.clear();
setState(() {
_selectedTeamId = null;
_statusFilter = 'not_submitted';
_searchQuery = '';
});
},
icon: const Icon(Icons.clear, size: 16),
label: const Text('Clear'),
),
],
),
),
// Task list — Skeletonizer lives inside Expanded so it always
// receives bounded height constraints (avoids bottom-overflow).
Expanded(
child: showSkeleton
? Skeletonizer(
enabled: true,
child: ListView.separated(
padding: const EdgeInsets.only(bottom: 80),
itemCount: 5,
separatorBuilder: (_, _) => const SizedBox(height: 4),
itemBuilder: (_, _) => _buildSkeletonTile(context),
),
)
: filtered.isEmpty
? Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.checklist,
size: 48, color: cs.onSurfaceVariant),
const SizedBox(height: 8),
Text(
'No completed tasks',
style: tt.bodyMedium
?.copyWith(color: cs.onSurfaceVariant),
),
],
),
)
: ListView.separated(
padding: const EdgeInsets.only(bottom: 80),
itemCount: filtered.length,
separatorBuilder: (_, _) => const SizedBox(height: 4),
itemBuilder: (context, index) {
final task = filtered[index];
final assignees = allAssignments
.where((a) => a.taskId == task.id)
.toList();
return _ItJobTile(
key: ValueKey(task.id),
task: task,
assignees: assignees,
profiles: profiles,
);
},
),
),
],
);
}
Widget _buildSkeletonTile(BuildContext context) {
return M3Card.outlined(
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Container(width: 60, height: 14, color: Colors.grey),
const SizedBox(width: 12),
Expanded(
child: Container(height: 14, color: Colors.grey),
),
const SizedBox(width: 12),
Container(width: 24, height: 24, color: Colors.grey),
],
),
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Per-task tile — stateful for optimistic checkbox + 60s bell cooldown
// ─────────────────────────────────────────────────────────────────────────────
class _ItJobTile extends ConsumerStatefulWidget {
const _ItJobTile({
super.key,
required this.task,
required this.assignees,
required this.profiles,
});
final Task task;
final List<TaskAssignment> assignees;
final List profiles;
@override
ConsumerState<_ItJobTile> createState() => _ItJobTileState();
}
class _ItJobTileState extends ConsumerState<_ItJobTile> {
static const _cooldownSeconds = 60;
bool? _optimisticChecked; // null = follow task.itJobPrinted
DateTime? _reminderSentAt;
Timer? _cooldownTimer;
int _secondsRemaining = 0;
@override
void dispose() {
_cooldownTimer?.cancel();
super.dispose();
}
bool get _isChecked => _optimisticChecked ?? widget.task.itJobPrinted;
bool get _inCooldown => _secondsRemaining > 0;
void _startCooldown() {
_reminderSentAt = DateTime.now();
_secondsRemaining = _cooldownSeconds;
_cooldownTimer?.cancel();
_cooldownTimer = Timer.periodic(const Duration(seconds: 1), (_) {
if (!mounted) return;
final elapsed =
DateTime.now().difference(_reminderSentAt!).inSeconds;
final remaining = (_cooldownSeconds - elapsed).clamp(0, _cooldownSeconds);
setState(() => _secondsRemaining = remaining);
if (remaining == 0) {
_cooldownTimer?.cancel();
_cooldownTimer = null;
}
});
}
Future<void> _toggleChecked(bool? val) async {
if (val == null) return;
setState(() => _optimisticChecked = val);
try {
final ctrl = ref.read(tasksControllerProvider);
if (val) {
final currentUserId = ref.read(currentUserIdProvider) ?? '';
await ctrl.markItJobPrinted(widget.task.id,
receivedById: currentUserId);
} else {
await ctrl.markItJobNotPrinted(widget.task.id);
}
// Keep optimistic state — do NOT clear it here.
// The realtime stream may arrive with a slight delay; clearing here
// causes a visible revert flash before the stream catches up.
} catch (e) {
// Revert optimistic state on failure only
if (mounted) {
setState(() => _optimisticChecked = !val);
showErrorSnackBar(context, 'Failed to update: $e');
}
}
}
Future<void> _sendReminder() async {
final userIds = widget.assignees.map((a) => a.userId).toList();
if (userIds.isEmpty) {
showErrorSnackBar(context, 'No assigned staff');
return;
}
try {
final currentUserId = ref.read(currentUserIdProvider);
await ref.read(notificationsControllerProvider).createNotification(
userIds: userIds,
type: 'it_job_reminder',
actorId: currentUserId ?? '',
fields: {'task_id': widget.task.id},
pushTitle: 'IT Job Reminder',
pushBody:
'Please submit printed IT Job for Task #${widget.task.taskNumber ?? widget.task.id.substring(0, 8)}',
pushData: {
'task_id': widget.task.id,
'navigate_to': '/tasks/${widget.task.id}',
},
);
if (mounted) {
setState(_startCooldown);
showSuccessSnackBar(context, 'Reminder sent');
}
} 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 received-by profile
String? receivedByName;
if (widget.task.itJobReceivedById != null) {
for (final p in widget.profiles) {
if (p.id == widget.task.itJobReceivedById) {
receivedByName = p.fullName as String?;
break;
}
}
receivedByName ??= 'Unknown';
}
return M3Card.outlined(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Task number — 150 px fixed; MonoText clips long numbers.
SizedBox(
width: 150,
child: MonoText(
widget.task.taskNumber ?? '-',
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
// Subject + assignees + received-by — Expanded so it never
// causes the Row to overflow on any screen width.
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.task.title,
style: tt.bodyMedium,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (widget.assignees.isNotEmpty) ...[
const SizedBox(height: 4),
Wrap(
spacing: 4,
runSpacing: 2,
children: widget.assignees.map((a) {
String name = 'Unknown';
String? avatarUrl;
for (final p in widget.profiles) {
if (p.id == a.userId) {
name = p.fullName as String;
avatarUrl = p.avatarUrl as String?;
break;
}
}
return InputChip(
avatar: ProfileAvatar(
fullName: name,
avatarUrl: avatarUrl,
radius: 12,
),
label: Text(name, style: tt.labelSmall),
visualDensity: VisualDensity.compact,
materialTapTargetSize:
MaterialTapTargetSize.shrinkWrap,
padding: EdgeInsets.zero,
onPressed: () {},
);
}).toList(),
),
],
// Received-by info shown inline — avoids a fixed-width
// column that overflows on narrow/mobile screens.
if (_isChecked) ...[
const SizedBox(height: 4),
Row(
children: [
Icon(Icons.check_circle_outline,
size: 14, color: cs.primary),
const SizedBox(width: 4),
Flexible(
child: Text(
receivedByName != null
? widget.task.itJobPrintedAt != null
? '$receivedByName · ${AppTime.formatDate(widget.task.itJobPrintedAt!)} ${AppTime.formatTime(widget.task.itJobPrintedAt!)}'
: receivedByName
: 'Submitted',
style:
tt.labelSmall?.copyWith(color: cs.primary),
overflow: TextOverflow.ellipsis,
),
),
],
),
],
],
),
),
// Compact checkbox — shrinkWrap removes the default 48 px touch
// target padding so it doesn't push the Row wider on mobile.
Checkbox(
value: _isChecked,
onChanged: _toggleChecked,
visualDensity: VisualDensity.compact,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
// Bell / cooldown button
SizedBox(
width: 40,
height: 40,
child: _inCooldown
? Tooltip(
message: '$_secondsRemaining s',
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: 'Remind assigned staff',
padding: EdgeInsets.zero,
onPressed: _sendReminder,
icon: Icon(
Icons.notifications_active_outlined,
color: cs.primary,
size: 20,
),
),
),
],
),
),
);
}
}
+103 -8
View File
@@ -33,6 +33,7 @@ import '../../widgets/app_state_view.dart';
import '../../utils/subject_suggestions.dart';
import '../../widgets/gemini_button.dart';
import '../../widgets/gemini_animated_text_field.dart';
import 'it_job_checklist_tab.dart';
// request metadata options used in task creation/editing dialogs
const List<String> _requestTypeOptions = [
@@ -57,14 +58,16 @@ class TasksListScreen extends ConsumerStatefulWidget {
}
class _TasksListScreenState extends ConsumerState<TasksListScreen>
with SingleTickerProviderStateMixin {
with TickerProviderStateMixin {
final TextEditingController _subjectController = TextEditingController();
final TextEditingController _taskNumberController = TextEditingController();
String? _selectedOfficeId;
String? _selectedStatus;
String? _selectedAssigneeId;
DateTimeRange? _selectedDateRange;
late final TabController _tabController;
late TabController _tabController;
bool _showItJobTab = false;
bool _tabInited = false;
@override
void dispose() {
@@ -78,13 +81,31 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
// Rebuild when tab changes so filter header can show/hide the
// "Assigned staff" dropdown for the All Tasks tab.
_tabController.addListener(() {
if (mounted) setState(() {});
});
}
void _ensureTabController(bool shouldShowItJobTab) {
if (_tabInited && _showItJobTab == shouldShowItJobTab) return;
final oldIndex = _tabController.index;
_tabController.removeListener(_onTabChange);
_tabController.dispose();
_tabController = TabController(
length: shouldShowItJobTab ? 3 : 2,
vsync: this,
initialIndex: oldIndex.clamp(0, shouldShowItJobTab ? 2 : 1),
);
_tabController.addListener(_onTabChange);
_tabInited = true;
_showItJobTab = shouldShowItJobTab;
setState(() {});
}
void _onTabChange() {
if (mounted) setState(() {});
}
bool get _hasTaskFilters {
return _subjectController.text.trim().isNotEmpty ||
_taskNumberController.text.trim().isNotEmpty ||
@@ -129,6 +150,13 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
profile.role == 'it_staff'),
orElse: () => false,
);
final role = profileAsync.valueOrNull?.role ?? '';
final shouldShowItJobTab = role == 'admin' || role == 'dispatcher';
if (!_tabInited || _showItJobTab != shouldShowItJobTab) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _ensureTabController(shouldShowItJobTab);
});
}
final ticketById = <String, Ticket>{
for (final ticket in ticketsAsync.valueOrNull ?? <Ticket>[])
@@ -521,9 +549,11 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
children: [
TabBar(
controller: _tabController,
tabs: const [
Tab(text: 'My Tasks'),
Tab(text: 'All Tasks'),
tabs: [
const Tab(text: 'My Tasks'),
const Tab(text: 'All Tasks'),
if (_showItJobTab)
const Tab(text: 'IT Job Checklist'),
],
),
Expanded(
@@ -552,6 +582,8 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
: 'Tasks created for your team will appear here.',
)
: makeList(filteredTasks),
if (_showItJobTab)
const ItJobChecklistTab(),
],
),
),
@@ -564,7 +596,20 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
),
),
),
if (canCreate)
if (_showItJobTab && _tabController.index == 2)
Positioned(
right: 16,
bottom: 16,
child: SafeArea(
child: M3ExpandedFab(
heroTag: 'notify_all_fab',
onPressed: () => _notifyAllPending(ref),
icon: const Icon(Icons.notifications_active),
label: const Text('Notify All Pending'),
),
),
)
else if (canCreate)
Positioned(
right: 16,
bottom: 16,
@@ -581,6 +626,56 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
);
}
Future<void> _notifyAllPending(WidgetRef ref) async {
final tasks = ref.read(tasksProvider).valueOrNull ?? [];
final assignments = ref.read(taskAssignmentsProvider).valueOrNull ?? [];
// All completed tasks with IT Job not yet submitted
final pending = tasks
.where((t) => t.status == 'completed' && !t.itJobPrinted)
.toList();
if (pending.isEmpty) {
if (mounted) showSuccessSnackBar(context, 'No pending IT Jobs to notify');
return;
}
// One notification per unique assignee (not per task)
final userIds = <String>{};
for (final task in pending) {
for (final a in assignments.where((a) => a.taskId == task.id)) {
userIds.add(a.userId);
}
}
if (userIds.isEmpty) {
if (mounted) showErrorSnackBar(context, 'No assigned staff found');
return;
}
try {
final currentUserId = ref.read(currentUserIdProvider);
// Single generic notification per user — no task_id to avoid flooding
await ref.read(notificationsControllerProvider).createNotification(
userIds: userIds.toList(),
type: 'it_job_reminder',
actorId: currentUserId ?? '',
pushTitle: 'IT Job Reminder',
pushBody:
'You have pending IT Job submissions. Please submit your printed copies to the dispatcher.',
pushData: {'navigate_to': '/tasks'},
);
if (mounted) {
showSuccessSnackBar(
context,
'Reminder sent to ${userIds.length} staff member(s)',
);
}
} catch (e) {
if (mounted) showErrorSnackBar(context, 'Failed to send: $e');
}
}
Future<void> _showCreateTaskDialog(
BuildContext context,
WidgetRef ref,