tasq/lib/screens/tasks/it_job_checklist_tab.dart

624 lines
24 KiB
Dart

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 to admin/dispatcher and IT staff.
///
/// Admin/dispatcher view: shows all completed tasks with a checkbox for
/// tracking printed IT Job submission status, plus a per-task notification
/// button with 60s cooldown.
///
/// IT staff view: shows only tasks assigned to the current user, read-only,
/// so they can track which IT Jobs still need a physical copy submitted.
class ItJobChecklistTab extends ConsumerStatefulWidget {
const ItJobChecklistTab({super.key, this.isAdminView = true});
/// When true, shows admin controls (checkbox, bell, stats, team filter).
/// When false (IT staff), shows only the current user's tasks, read-only.
final bool isAdminView;
@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;
final currentUserId = widget.isAdminView
? null
: ref.watch(currentUserIdProvider);
// All completed tasks
var filtered = allTasks.where((t) => t.status == 'completed').toList();
// IT staff: restrict to tasks assigned to the current user
if (!widget.isAdminView && currentUserId != null) {
filtered = filtered.where((t) {
return allAssignments
.any((a) => a.taskId == t.id && a.userId == currentUserId);
}).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 (admin/dispatcher only)
if (widget.isAdminView && _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 (admin/dispatcher only — computed over all completed tasks)
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 — admin/dispatcher only
if (widget.isAdminView)
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 — admin/dispatcher only
if (widget.isAdminView)
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 ((widget.isAdminView && _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,
isAdminView: widget.isAdminView,
);
},
),
),
],
);
}
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,
this.isAdminView = true,
});
final Task task;
final List<TaskAssignment> assignees;
final List profiles;
final bool isAdminView;
@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');
}
}
}
void _sendReminder() {
final userIds = widget.assignees.map((a) => a.userId).toList();
if (userIds.isEmpty) {
showErrorSnackBar(context, 'No assigned staff');
return;
}
// Start cooldown and give immediate feedback — notification fires async.
setState(_startCooldown);
showSuccessSnackBar(context, 'Reminder sent');
final currentUserId = ref.read(currentUserIdProvider);
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}',
},
)
.ignore();
}
@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 + bell — admin/dispatcher only
if (widget.isAdminView) ...[
// 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(
color: cs.primary,
),
),
],
),
)
: IconButton(
tooltip: 'Remind assigned staff',
padding: EdgeInsets.zero,
onPressed: _sendReminder,
icon: Icon(
Icons.notifications_active_outlined,
color: cs.primary,
size: 20,
),
),
),
],
],
),
),
);
}
}