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 createState() => _ItJobChecklistTabState(); } class _ItJobChecklistTabState extends ConsumerState { 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( 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( value: null, child: Text('All Teams'), ), ...teams.map((t) => DropdownMenuItem( value: t.id, child: Text(t.name, overflow: TextOverflow.ellipsis), )), ], onChanged: (v) => setState(() => _selectedTeamId = v), ), ), // Status filter SizedBox( width: 180, child: DropdownButtonFormField( 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( 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 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 _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, ), ), ), ], ], ), ), ); } }