Announcements and IT Job Checklist
This commit is contained in:
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user