diff --git a/lib/screens/tasks/it_job_checklist_tab.dart b/lib/screens/tasks/it_job_checklist_tab.dart index 4b36d38c..ce1d32b4 100644 --- a/lib/screens/tasks/it_job_checklist_tab.dart +++ b/lib/screens/tasks/it_job_checklist_tab.dart @@ -12,6 +12,7 @@ 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'; @@ -40,6 +41,7 @@ class _ItJobChecklistTabState extends ConsumerState { String? _statusFilter = 'not_submitted'; final _searchController = TextEditingController(); String _searchQuery = ''; + bool _showFilters = false; @override void dispose() { @@ -47,11 +49,18 @@ class _ItJobChecklistTabState extends ConsumerState { 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 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 ?? []; @@ -115,222 +124,113 @@ class _ItJobChecklistTabState extends ConsumerState { // 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 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, - ), - ), - ], + 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, ), ), - ), - ), - // 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'), - ), - ], + // 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; + }); + }, ), - ), - // 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( + // 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: 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, - ); - }, + 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) { + Widget _buildSkeletonTile(BuildContext context, bool isWide) { return M3Card.outlined( child: Padding( padding: const EdgeInsets.all(12), @@ -341,6 +241,12 @@ class _ItJobChecklistTabState extends ConsumerState { 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), ], @@ -350,6 +256,489 @@ class _ItJobChecklistTabState extends ConsumerState { } } +// ───────────────────────────────────────────────────────────────────────────── +// 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 onSearchChanged; + final VoidCallback onToggleFilters; + final ValueChanged onTeamChanged; + final ValueChanged 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( + 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( + value: null, + child: Text('All Teams'), + ), + ...teams.map((t) => DropdownMenuItem( + 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( + 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(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 // ───────────────────────────────────────────────────────────────────────────── @@ -361,12 +750,14 @@ class _ItJobTile extends ConsumerStatefulWidget { required this.assignees, required this.profiles, this.isAdminView = true, + this.isWide = false, }); final Task task; final List assignees; final List profiles; final bool isAdminView; + final bool isWide; @override ConsumerState<_ItJobTile> createState() => _ItJobTileState(); @@ -398,7 +789,8 @@ class _ItJobTileState extends ConsumerState<_ItJobTile> { if (!mounted) return; final elapsed = DateTime.now().difference(_reminderSentAt!).inSeconds; - final remaining = (_cooldownSeconds - elapsed).clamp(0, _cooldownSeconds); + final remaining = + (_cooldownSeconds - elapsed).clamp(0, _cooldownSeconds); setState(() => _secondsRemaining = remaining); if (remaining == 0) { _cooldownTimer?.cancel(); @@ -425,10 +817,9 @@ class _ItJobTileState extends ConsumerState<_ItJobTile> { } catch (e) { if (mounted) { if (isOfflineSaveError(e)) { - // Brick queued the write — keep optimistic state, just notify user - showSuccessSnackBar(context, 'Saved offline — will sync when connected.'); + showSuccessSnackBar( + context, 'Saved offline — will sync when connected.'); } else { - // Revert optimistic state on real failure setState(() => _optimisticChecked = !val); showErrorSnackBar(context, 'Failed to update: $e'); } @@ -443,7 +834,6 @@ class _ItJobTileState extends ConsumerState<_ItJobTile> { return; } - // Start cooldown and give immediate feedback — notification fires async. setState(_startCooldown); showSuccessSnackBar(context, 'Reminder sent'); @@ -483,146 +873,249 @@ class _ItJobTileState extends ConsumerState<_ItJobTile> { 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, - ), + 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, ), - 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, - ), - ), - ], - ), - ], + ), + ], + ), + ); + + 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), ], - ), + ], ), - // 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, - ), - ), - ), - ], - ], + ), + 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, + ), + ), + ], + ); + } }