Files
tasq/lib/screens/tasks/it_job_checklist_tab.dart
T

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,
),
),
],
);
}
}