1122 lines
37 KiB
Dart
1122 lines
37 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/app_breakpoints.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 = '';
|
|
bool _showFilters = false;
|
|
|
|
@override
|
|
void dispose() {
|
|
_searchController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
int get _activeFilterCount {
|
|
int count = 0;
|
|
if (widget.isAdminView && _selectedTeamId != null) count++;
|
|
if (_statusFilter != 'not_submitted') count++;
|
|
return count;
|
|
}
|
|
|
|
bool get _hasAnyFilter =>
|
|
_searchQuery.isNotEmpty || _activeFilterCount > 0;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
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 pending = allCompleted.length - submitted;
|
|
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 LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final isWide = constraints.maxWidth >= AppBreakpoints.tablet;
|
|
final isMobile = constraints.maxWidth < AppBreakpoints.mobile;
|
|
|
|
return Column(
|
|
children: [
|
|
// Stats card — admin/dispatcher only
|
|
if (widget.isAdminView)
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(0, 12, 0, 8),
|
|
child: _StatsCard(
|
|
submitted: submitted,
|
|
pending: pending,
|
|
total: total,
|
|
isWide: isWide,
|
|
),
|
|
),
|
|
|
|
// Filter bar — responsive
|
|
_FilterBar(
|
|
isMobile: isMobile,
|
|
isWide: isWide,
|
|
isAdminView: widget.isAdminView,
|
|
showFilters: _showFilters,
|
|
activeFilterCount: _activeFilterCount,
|
|
hasAnyFilter: _hasAnyFilter,
|
|
searchController: _searchController,
|
|
searchQuery: _searchQuery,
|
|
selectedTeamId: _selectedTeamId,
|
|
statusFilter: _statusFilter,
|
|
teams: teams,
|
|
onSearchChanged: (v) => setState(() => _searchQuery = v.trim()),
|
|
onToggleFilters: () =>
|
|
setState(() => _showFilters = !_showFilters),
|
|
onTeamChanged: (v) => setState(() => _selectedTeamId = v),
|
|
onStatusChanged: (v) => setState(() => _statusFilter = v),
|
|
onClear: () {
|
|
_searchController.clear();
|
|
setState(() {
|
|
_selectedTeamId = null;
|
|
_statusFilter = 'not_submitted';
|
|
_searchQuery = '';
|
|
_showFilters = false;
|
|
});
|
|
},
|
|
),
|
|
|
|
// Column headers — wide screens only
|
|
if (isWide)
|
|
_ChecklistHeader(isAdminView: widget.isAdminView),
|
|
|
|
// 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, isWide),
|
|
),
|
|
)
|
|
: filtered.isEmpty
|
|
? _EmptyState(
|
|
hasFilters: _hasAnyFilter,
|
|
allSubmitted: total > 0 && pending == 0,
|
|
)
|
|
: 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,
|
|
isWide: isWide,
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildSkeletonTile(BuildContext context, bool isWide) {
|
|
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),
|
|
),
|
|
if (isWide) ...[
|
|
const SizedBox(width: 12),
|
|
Container(width: 80, height: 22, color: Colors.grey),
|
|
const SizedBox(width: 12),
|
|
Container(width: 120, height: 14, color: Colors.grey),
|
|
],
|
|
const SizedBox(width: 12),
|
|
Container(width: 24, height: 24, color: Colors.grey),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Stats card — shows submitted/pending counts and progress bar
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
class _StatsCard extends StatelessWidget {
|
|
const _StatsCard({
|
|
required this.submitted,
|
|
required this.pending,
|
|
required this.total,
|
|
required this.isWide,
|
|
});
|
|
|
|
final int submitted;
|
|
final int pending;
|
|
final int total;
|
|
final bool isWide;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final cs = Theme.of(context).colorScheme;
|
|
final tt = Theme.of(context).textTheme;
|
|
final pct = total > 0 ? submitted / total : 0.0;
|
|
final pctLabel = total > 0
|
|
? '${(pct * 100).round()}% complete'
|
|
: 'No tasks';
|
|
|
|
final progressBar = ClipRRect(
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: LinearProgressIndicator(
|
|
value: pct,
|
|
minHeight: 8,
|
|
backgroundColor: cs.surfaceContainerLow,
|
|
valueColor: AlwaysStoppedAnimation(cs.primary),
|
|
),
|
|
);
|
|
|
|
final submittedChip = _StatChip(
|
|
icon: Icons.check_circle_outline,
|
|
label: 'Submitted',
|
|
count: submitted,
|
|
color: cs.primary,
|
|
bgColor: cs.primaryContainer,
|
|
);
|
|
|
|
final pendingChip = _StatChip(
|
|
icon: Icons.pending_outlined,
|
|
label: 'Pending',
|
|
count: pending,
|
|
color: cs.tertiary,
|
|
bgColor: cs.tertiaryContainer,
|
|
);
|
|
|
|
return M3Card.filled(
|
|
color: cs.surfaceContainerHighest,
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: isWide
|
|
? 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 SizedBox(width: 16),
|
|
submittedChip,
|
|
const SizedBox(width: 8),
|
|
pendingChip,
|
|
const SizedBox(width: 16),
|
|
Expanded(child: progressBar),
|
|
const SizedBox(width: 12),
|
|
Text(
|
|
pctLabel,
|
|
style: tt.labelLarge?.copyWith(
|
|
color: cs.primary,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
],
|
|
)
|
|
: 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(
|
|
pctLabel,
|
|
style: tt.labelLarge?.copyWith(
|
|
color: cs.primary,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 10),
|
|
progressBar,
|
|
const SizedBox(height: 10),
|
|
Row(
|
|
children: [
|
|
submittedChip,
|
|
const SizedBox(width: 8),
|
|
pendingChip,
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _StatChip extends StatelessWidget {
|
|
const _StatChip({
|
|
required this.icon,
|
|
required this.label,
|
|
required this.count,
|
|
required this.color,
|
|
required this.bgColor,
|
|
});
|
|
|
|
final IconData icon;
|
|
final String label;
|
|
final int count;
|
|
final Color color;
|
|
final Color bgColor;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final tt = Theme.of(context).textTheme;
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
|
decoration: BoxDecoration(
|
|
color: bgColor,
|
|
borderRadius: BorderRadius.circular(20),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(icon, size: 14, color: color),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
'$count $label',
|
|
style: tt.labelSmall?.copyWith(
|
|
color: color,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Responsive filter bar
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
class _FilterBar extends StatelessWidget {
|
|
const _FilterBar({
|
|
required this.isMobile,
|
|
required this.isWide,
|
|
required this.isAdminView,
|
|
required this.showFilters,
|
|
required this.activeFilterCount,
|
|
required this.hasAnyFilter,
|
|
required this.searchController,
|
|
required this.searchQuery,
|
|
required this.selectedTeamId,
|
|
required this.statusFilter,
|
|
required this.teams,
|
|
required this.onSearchChanged,
|
|
required this.onToggleFilters,
|
|
required this.onTeamChanged,
|
|
required this.onStatusChanged,
|
|
required this.onClear,
|
|
});
|
|
|
|
final bool isMobile;
|
|
final bool isWide;
|
|
final bool isAdminView;
|
|
final bool showFilters;
|
|
final int activeFilterCount;
|
|
final bool hasAnyFilter;
|
|
final TextEditingController searchController;
|
|
final String searchQuery;
|
|
final String? selectedTeamId;
|
|
final String? statusFilter;
|
|
final List teams;
|
|
final ValueChanged<String> onSearchChanged;
|
|
final VoidCallback onToggleFilters;
|
|
final ValueChanged<String?> onTeamChanged;
|
|
final ValueChanged<String?> onStatusChanged;
|
|
final VoidCallback onClear;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final cs = Theme.of(context).colorScheme;
|
|
|
|
final searchField = TextField(
|
|
controller: searchController,
|
|
decoration: InputDecoration(
|
|
labelText: 'Search task # or subject',
|
|
isDense: true,
|
|
filled: true,
|
|
fillColor: cs.surfaceContainerLow,
|
|
contentPadding:
|
|
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
borderSide: BorderSide.none,
|
|
),
|
|
enabledBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
borderSide: BorderSide.none,
|
|
),
|
|
prefixIcon: const Icon(Icons.search, size: 18),
|
|
suffixIcon: searchQuery.isNotEmpty
|
|
? IconButton(
|
|
icon: const Icon(Icons.clear, size: 16),
|
|
onPressed: () => onSearchChanged(''),
|
|
)
|
|
: null,
|
|
),
|
|
onChanged: onSearchChanged,
|
|
);
|
|
|
|
final teamDropdown = isAdminView
|
|
? SizedBox(
|
|
width: isWide ? 180 : double.infinity,
|
|
child: DropdownButtonFormField<String>(
|
|
key: ValueKey(selectedTeamId),
|
|
initialValue: selectedTeamId,
|
|
decoration: InputDecoration(
|
|
labelText: 'Team',
|
|
isDense: true,
|
|
filled: true,
|
|
fillColor: cs.surfaceContainerLow,
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: 12, vertical: 8),
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
borderSide: BorderSide.none,
|
|
),
|
|
enabledBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
borderSide: BorderSide.none,
|
|
),
|
|
),
|
|
isExpanded: true,
|
|
items: [
|
|
const DropdownMenuItem<String>(
|
|
value: null,
|
|
child: Text('All Teams'),
|
|
),
|
|
...teams.map((t) => DropdownMenuItem<String>(
|
|
value: t.id as String,
|
|
child: Text(t.name as String,
|
|
overflow: TextOverflow.ellipsis),
|
|
)),
|
|
],
|
|
onChanged: onTeamChanged,
|
|
),
|
|
)
|
|
: null;
|
|
|
|
final statusDropdown = SizedBox(
|
|
width: isWide ? 180 : double.infinity,
|
|
child: DropdownButtonFormField<String>(
|
|
key: ValueKey(statusFilter),
|
|
initialValue: statusFilter,
|
|
decoration: InputDecoration(
|
|
labelText: 'Status',
|
|
isDense: true,
|
|
filled: true,
|
|
fillColor: cs.surfaceContainerLow,
|
|
contentPadding:
|
|
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
borderSide: BorderSide.none,
|
|
),
|
|
enabledBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
borderSide: BorderSide.none,
|
|
),
|
|
),
|
|
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: onStatusChanged,
|
|
),
|
|
);
|
|
|
|
final clearButton = hasAnyFilter
|
|
? TextButton.icon(
|
|
onPressed: onClear,
|
|
icon: const Icon(Icons.clear, size: 16),
|
|
label: const Text('Clear'),
|
|
)
|
|
: null;
|
|
|
|
if (isMobile) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 6),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(child: searchField),
|
|
const SizedBox(width: 8),
|
|
Badge(
|
|
isLabelVisible: activeFilterCount > 0,
|
|
label: Text('$activeFilterCount'),
|
|
child: IconButton.filledTonal(
|
|
tooltip: 'Filters',
|
|
onPressed: onToggleFilters,
|
|
icon: Icon(
|
|
showFilters
|
|
? Icons.filter_list_off
|
|
: Icons.filter_list,
|
|
),
|
|
),
|
|
),
|
|
?clearButton,
|
|
],
|
|
),
|
|
AnimatedSize(
|
|
duration: const Duration(milliseconds: 200),
|
|
curve: Curves.easeInOut,
|
|
child: showFilters
|
|
? Padding(
|
|
padding: const EdgeInsets.only(top: 8),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
?teamDropdown,
|
|
if (teamDropdown != null) const SizedBox(height: 8),
|
|
statusDropdown,
|
|
],
|
|
),
|
|
)
|
|
: const SizedBox.shrink(),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// Tablet / desktop: inline wrap
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
child: Wrap(
|
|
spacing: 12,
|
|
runSpacing: 8,
|
|
crossAxisAlignment: WrapCrossAlignment.center,
|
|
children: [
|
|
SizedBox(width: 240, child: searchField),
|
|
?teamDropdown,
|
|
statusDropdown,
|
|
?clearButton,
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Column header row — shown on wide screens above the list
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
class _ChecklistHeader extends StatelessWidget {
|
|
const _ChecklistHeader({required this.isAdminView});
|
|
|
|
final bool isAdminView;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final cs = Theme.of(context).colorScheme;
|
|
final tt = Theme.of(context).textTheme;
|
|
final labelStyle = tt.labelSmall?.copyWith(
|
|
color: cs.onSurfaceVariant,
|
|
fontWeight: FontWeight.w600,
|
|
letterSpacing: 0.5,
|
|
);
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(0, 4, 0, 2),
|
|
child: Row(
|
|
children: [
|
|
SizedBox(
|
|
width: 140,
|
|
child: Padding(
|
|
padding: const EdgeInsets.only(left: 15),
|
|
child: Text('TASK #', style: labelStyle),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
flex: 3,
|
|
child: Text('SUBJECT & ASSIGNEES', style: labelStyle),
|
|
),
|
|
SizedBox(
|
|
width: 110,
|
|
child: Center(child: Text('STATUS', style: labelStyle)),
|
|
),
|
|
Expanded(
|
|
flex: 2,
|
|
child: Padding(
|
|
padding: const EdgeInsets.only(left: 8),
|
|
child: Text('RECEIVED BY', style: labelStyle),
|
|
),
|
|
),
|
|
if (isAdminView) const SizedBox(width: 80),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Empty state — context-aware messaging
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
class _EmptyState extends StatelessWidget {
|
|
const _EmptyState({required this.hasFilters, required this.allSubmitted});
|
|
|
|
final bool hasFilters;
|
|
final bool allSubmitted;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final cs = Theme.of(context).colorScheme;
|
|
final tt = Theme.of(context).textTheme;
|
|
|
|
final IconData icon;
|
|
final String message;
|
|
final Color iconColor;
|
|
|
|
if (allSubmitted && !hasFilters) {
|
|
icon = Icons.task_alt;
|
|
message = 'All IT Jobs submitted!';
|
|
iconColor = cs.primary;
|
|
} else if (hasFilters) {
|
|
icon = Icons.search_off;
|
|
message = 'No tasks match your filters';
|
|
iconColor = cs.onSurfaceVariant;
|
|
} else {
|
|
icon = Icons.checklist;
|
|
message = 'No completed tasks yet';
|
|
iconColor = cs.onSurfaceVariant;
|
|
}
|
|
|
|
return Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(icon, size: 56, color: iconColor),
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
message,
|
|
style: tt.bodyMedium?.copyWith(color: cs.onSurfaceVariant),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// 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,
|
|
this.isWide = false,
|
|
});
|
|
|
|
final Task task;
|
|
final List<TaskAssignment> assignees;
|
|
final List profiles;
|
|
final bool isAdminView;
|
|
final bool isWide;
|
|
|
|
@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) {
|
|
if (mounted) {
|
|
if (isOfflineSaveError(e)) {
|
|
showSuccessSnackBar(
|
|
context, 'Saved offline — will sync when connected.');
|
|
} else {
|
|
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;
|
|
}
|
|
|
|
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';
|
|
}
|
|
|
|
final accentColor = _isChecked ? cs.primary : cs.outlineVariant;
|
|
final tileBgColor =
|
|
_isChecked ? cs.primaryContainer.withValues(alpha: 0.18) : null;
|
|
|
|
// Status badge
|
|
final statusBadge = Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
|
decoration: BoxDecoration(
|
|
color: _isChecked ? cs.primaryContainer : cs.tertiaryContainer,
|
|
borderRadius: BorderRadius.circular(20),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
_isChecked
|
|
? Icons.check_circle_outline
|
|
: Icons.hourglass_empty_outlined,
|
|
size: 12,
|
|
color: _isChecked ? cs.primary : cs.tertiary,
|
|
),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
_isChecked ? 'Submitted' : 'Pending',
|
|
style: tt.labelSmall?.copyWith(
|
|
color: _isChecked ? cs.primary : cs.tertiary,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
Widget tileContent;
|
|
|
|
if (widget.isWide) {
|
|
// Desktop / tablet: aligned columns matching _ChecklistHeader
|
|
tileContent = Row(
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
SizedBox(
|
|
width: 140,
|
|
child: MonoText(
|
|
widget.task.taskNumber ?? '-',
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
flex: 3,
|
|
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),
|
|
_buildAssigneeChips(tt),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
SizedBox(
|
|
width: 110,
|
|
child: Center(child: statusBadge),
|
|
),
|
|
Expanded(
|
|
flex: 2,
|
|
child: Padding(
|
|
padding: const EdgeInsets.only(left: 8),
|
|
child: _receivedByText(receivedByName, cs, tt),
|
|
),
|
|
),
|
|
if (widget.isAdminView)
|
|
SizedBox(width: 80, child: _buildActionsRow(cs, tt)),
|
|
],
|
|
);
|
|
} else {
|
|
// Mobile: stacked layout
|
|
tileContent = Row(
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
MonoText(
|
|
widget.task.taskNumber ?? '-',
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
const SizedBox(width: 8),
|
|
statusBadge,
|
|
],
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
widget.task.title,
|
|
style: tt.bodyMedium,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
if (widget.assignees.isNotEmpty) ...[
|
|
const SizedBox(height: 4),
|
|
_buildAssigneeChips(tt),
|
|
],
|
|
if (_isChecked && receivedByName != null) ...[
|
|
const SizedBox(height: 4),
|
|
_receivedByText(receivedByName, cs, tt),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
if (widget.isAdminView) _buildActionsRow(cs, tt),
|
|
],
|
|
);
|
|
}
|
|
|
|
return ClipRRect(
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: M3Card.filled(
|
|
color: tileBgColor,
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
border: Border(
|
|
left: BorderSide(color: accentColor, width: 3),
|
|
),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
|
child: tileContent,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildAssigneeChips(TextTheme tt) {
|
|
return 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(),
|
|
);
|
|
}
|
|
|
|
Widget _buildActionsRow(ColorScheme cs, TextTheme tt) {
|
|
return Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Checkbox(
|
|
value: _isChecked,
|
|
onChanged: _toggleChecked,
|
|
visualDensity: VisualDensity.compact,
|
|
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
|
),
|
|
SizedBox(
|
|
width: 36,
|
|
height: 36,
|
|
child: _inCooldown
|
|
? Tooltip(
|
|
message: '$_secondsRemaining s',
|
|
child: Stack(
|
|
alignment: Alignment.center,
|
|
children: [
|
|
SizedBox(
|
|
width: 26,
|
|
height: 26,
|
|
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,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _receivedByText(
|
|
String? name, ColorScheme cs, TextTheme tt) {
|
|
if (name == null) return const SizedBox.shrink();
|
|
return Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(Icons.person_outline, size: 13, color: cs.onSurfaceVariant),
|
|
const SizedBox(width: 3),
|
|
Flexible(
|
|
child: Text(
|
|
widget.task.itJobPrintedAt != null
|
|
? '$name · ${AppTime.formatDate(widget.task.itJobPrintedAt!)} ${AppTime.formatTime(widget.task.itJobPrintedAt!)}'
|
|
: name,
|
|
style: tt.labelSmall?.copyWith(color: cs.onSurfaceVariant),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|