import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../models/notification_item.dart'; import '../../models/office.dart'; import '../../models/profile.dart'; import '../../models/task.dart'; import '../../models/ticket.dart'; import '../../providers/notifications_provider.dart'; import '../../providers/profile_provider.dart'; import '../../providers/tasks_provider.dart'; import '../../providers/tickets_provider.dart'; import '../../providers/typing_provider.dart'; import '../../utils/app_time.dart'; import '../../widgets/mono_text.dart'; import '../../widgets/responsive_body.dart'; import '../../widgets/tasq_adaptive_list.dart'; import '../../widgets/typing_dots.dart'; class TasksListScreen extends ConsumerStatefulWidget { const TasksListScreen({super.key}); @override ConsumerState createState() => _TasksListScreenState(); } class _TasksListScreenState extends ConsumerState { final TextEditingController _subjectController = TextEditingController(); String? _selectedOfficeId; String? _selectedStatus; String? _selectedAssigneeId; DateTimeRange? _selectedDateRange; @override void dispose() { _subjectController.dispose(); super.dispose(); } bool get _hasTaskFilters { return _subjectController.text.trim().isNotEmpty || _selectedOfficeId != null || _selectedStatus != null || _selectedAssigneeId != null || _selectedDateRange != null; } @override Widget build(BuildContext context) { final tasksAsync = ref.watch(tasksProvider); final ticketsAsync = ref.watch(ticketsProvider); final officesAsync = ref.watch(officesProvider); final profileAsync = ref.watch(currentProfileProvider); final notificationsAsync = ref.watch(notificationsProvider); final profilesAsync = ref.watch(profilesProvider); final canCreate = profileAsync.maybeWhen( data: (profile) => profile != null && (profile.role == 'admin' || profile.role == 'dispatcher' || profile.role == 'it_staff'), orElse: () => false, ); final ticketById = { for (final ticket in ticketsAsync.valueOrNull ?? []) ticket.id: ticket, }; final officeById = { for (final office in officesAsync.valueOrNull ?? []) office.id: office, }; final profileById = { for (final profile in profilesAsync.valueOrNull ?? []) profile.id: profile, }; return Stack( children: [ ResponsiveBody( maxWidth: double.infinity, child: tasksAsync.when( data: (tasks) { if (tasks.isEmpty) { return const Center(child: Text('No tasks yet.')); } final offices = officesAsync.valueOrNull ?? []; final officeOptions = >[ const DropdownMenuItem( value: null, child: Text('All offices'), ), ...offices.map( (office) => DropdownMenuItem( value: office.id, child: Text(office.name), ), ), ]; final staffOptions = _staffOptions(profilesAsync.valueOrNull); final statusOptions = _taskStatusOptions(tasks); final filteredTasks = _applyTaskFilters( tasks, ticketById: ticketById, subjectQuery: _subjectController.text, officeId: _selectedOfficeId, status: _selectedStatus, assigneeId: _selectedAssigneeId, dateRange: _selectedDateRange, ); final summaryDashboard = _StatusSummaryRow( counts: _taskStatusCounts(filteredTasks), ); final filterHeader = Wrap( spacing: 12, runSpacing: 12, crossAxisAlignment: WrapCrossAlignment.center, children: [ SizedBox( width: 220, child: TextField( controller: _subjectController, onChanged: (_) => setState(() {}), decoration: const InputDecoration( labelText: 'Subject', prefixIcon: Icon(Icons.search), ), ), ), SizedBox( width: 200, child: DropdownButtonFormField( isExpanded: true, key: ValueKey(_selectedOfficeId), initialValue: _selectedOfficeId, items: officeOptions, onChanged: (value) => setState(() => _selectedOfficeId = value), decoration: const InputDecoration(labelText: 'Office'), ), ), SizedBox( width: 220, child: DropdownButtonFormField( isExpanded: true, key: ValueKey(_selectedAssigneeId), initialValue: _selectedAssigneeId, items: staffOptions, onChanged: (value) => setState(() => _selectedAssigneeId = value), decoration: const InputDecoration( labelText: 'Assigned staff', ), ), ), SizedBox( width: 180, child: DropdownButtonFormField( isExpanded: true, key: ValueKey(_selectedStatus), initialValue: _selectedStatus, items: statusOptions, onChanged: (value) => setState(() => _selectedStatus = value), decoration: const InputDecoration(labelText: 'Status'), ), ), OutlinedButton.icon( onPressed: () async { final next = await showDateRangePicker( context: context, firstDate: DateTime(2020), lastDate: AppTime.now().add(const Duration(days: 365)), currentDate: AppTime.now(), initialDateRange: _selectedDateRange, ); if (!mounted) return; setState(() => _selectedDateRange = next); }, icon: const Icon(Icons.date_range), label: Text( _selectedDateRange == null ? 'Date range' : _formatDateRange(_selectedDateRange!), ), ), if (_hasTaskFilters) TextButton.icon( onPressed: () => setState(() { _subjectController.clear(); _selectedOfficeId = null; _selectedStatus = null; _selectedAssigneeId = null; _selectedDateRange = null; }), icon: const Icon(Icons.close), label: const Text('Clear'), ), ], ); final listBody = TasQAdaptiveList( items: filteredTasks, onRowTap: (task) => context.go('/tasks/${task.id}'), summaryDashboard: summaryDashboard, filterHeader: filterHeader, onRequestRefresh: () { // For server-side pagination, update the query provider ref.read(tasksQueryProvider.notifier).state = const TaskQuery(offset: 0, limit: 50); }, isLoading: false, columns: [ TasQColumn( header: 'Task ID', technical: true, cellBuilder: (context, task) => Text(task.id), ), TasQColumn( header: 'Subject', cellBuilder: (context, task) { final ticket = task.ticketId == null ? null : ticketById[task.ticketId]; return Text( task.title.isNotEmpty ? task.title : (ticket?.subject ?? 'Task ${task.id}'), ); }, ), TasQColumn( header: 'Office', cellBuilder: (context, task) { final ticket = task.ticketId == null ? null : ticketById[task.ticketId]; final officeId = ticket?.officeId ?? task.officeId; return Text( officeId == null ? 'Unassigned office' : (officeById[officeId]?.name ?? officeId), ); }, ), TasQColumn( header: 'Assigned Agent', cellBuilder: (context, task) => Text(_assignedAgent(profileById, task.creatorId)), ), TasQColumn( header: 'Status', cellBuilder: (context, task) => _StatusBadge(status: task.status), ), TasQColumn( header: 'Timestamp', technical: true, cellBuilder: (context, task) => Text(_formatTimestamp(task.createdAt)), ), ], mobileTileBuilder: (context, task, actions) { final ticketId = task.ticketId; final ticket = ticketId == null ? null : ticketById[ticketId]; final officeId = ticket?.officeId ?? task.officeId; final officeName = officeId == null ? 'Unassigned office' : (officeById[officeId]?.name ?? officeId); final assigned = _assignedAgent(profileById, task.creatorId); final subtitle = _buildSubtitle(officeName, task.status); final hasMention = _hasTaskMention(notificationsAsync, task); final typingState = ref.watch( typingIndicatorProvider(task.id), ); final showTyping = typingState.userIds.isNotEmpty; return Card( child: ListTile( leading: _buildQueueBadge(context, task), dense: true, visualDensity: VisualDensity.compact, title: Text( task.title.isNotEmpty ? task.title : (ticket?.subject ?? 'Task ${task.id}'), ), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(subtitle), const SizedBox(height: 2), Text('Assigned: $assigned'), const SizedBox(height: 4), MonoText('ID ${task.id}'), const SizedBox(height: 2), Text(_formatTimestamp(task.createdAt)), ], ), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ _StatusBadge(status: task.status), if (showTyping) ...[ const SizedBox(width: 6), TypingDots( size: 6, color: Theme.of(context).colorScheme.primary, ), ], if (hasMention) const Padding( padding: EdgeInsets.only(left: 8), child: Icon( Icons.circle, size: 10, color: Colors.red, ), ), ], ), onTap: () => context.go('/tasks/${task.id}'), ), ); }, ); return Column( mainAxisSize: MainAxisSize.max, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Padding( padding: const EdgeInsets.only(top: 16, bottom: 8), child: Align( alignment: Alignment.center, child: Text( 'Tasks', textAlign: TextAlign.center, style: Theme.of(context).textTheme.titleLarge?.copyWith( fontWeight: FontWeight.w700, ), ), ), ), Expanded(child: listBody), ], ); }, loading: () => const Center(child: CircularProgressIndicator()), error: (error, _) => Center(child: Text('Failed to load tasks: $error')), ), ), if (canCreate) Positioned( right: 16, bottom: 16, child: SafeArea( child: FloatingActionButton.extended( onPressed: () => _showCreateTaskDialog(context, ref), icon: const Icon(Icons.add), label: const Text('New Task'), ), ), ), ], ); } Future _showCreateTaskDialog( BuildContext context, WidgetRef ref, ) async { final titleController = TextEditingController(); final descriptionController = TextEditingController(); String? selectedOfficeId; await showDialog( context: context, builder: (dialogContext) { return StatefulBuilder( builder: (context, setState) { final officesAsync = ref.watch(officesProvider); return AlertDialog( title: const Text('Create Task'), content: SizedBox( width: 360, child: Column( mainAxisSize: MainAxisSize.min, children: [ TextField( controller: titleController, decoration: const InputDecoration( labelText: 'Task title', ), ), const SizedBox(height: 12), TextField( controller: descriptionController, decoration: const InputDecoration( labelText: 'Description', ), maxLines: 3, ), const SizedBox(height: 12), officesAsync.when( data: (offices) { if (offices.isEmpty) { return const Text('No offices available.'); } selectedOfficeId ??= offices.first.id; return DropdownButtonFormField( initialValue: selectedOfficeId, decoration: const InputDecoration( labelText: 'Office', ), items: offices .map( (office) => DropdownMenuItem( value: office.id, child: Text(office.name), ), ) .toList(), onChanged: (value) => setState(() => selectedOfficeId = value), ); }, loading: () => const Align( alignment: Alignment.centerLeft, child: CircularProgressIndicator(), ), error: (error, _) => Text('Failed to load offices: $error'), ), ], ), ), actions: [ TextButton( onPressed: () => Navigator.of(dialogContext).pop(), child: const Text('Cancel'), ), FilledButton( onPressed: () async { final title = titleController.text.trim(); final description = descriptionController.text.trim(); final officeId = selectedOfficeId; if (title.isEmpty || officeId == null) { return; } await ref .read(tasksControllerProvider) .createTask( title: title, description: description, officeId: officeId, ); if (context.mounted) { Navigator.of(dialogContext).pop(); } }, child: const Text('Create'), ), ], ); }, ); }, ); } bool _hasTaskMention( AsyncValue> notificationsAsync, Task task, ) { return notificationsAsync.maybeWhen( data: (items) => items.any( (item) => item.isUnread && (item.taskId == task.id || item.ticketId == task.ticketId), ), orElse: () => false, ); } Widget _buildQueueBadge(BuildContext context, Task task) { final queueOrder = task.queueOrder; final isQueued = task.status == 'queued'; if (!isQueued || queueOrder == null) { return const Icon(Icons.fact_check_outlined); } return Container( width: 40, height: 40, alignment: Alignment.center, decoration: BoxDecoration( color: Theme.of(context).colorScheme.primaryContainer, borderRadius: BorderRadius.circular(12), ), child: Text( '#$queueOrder', style: Theme.of(context).textTheme.labelMedium?.copyWith( fontWeight: FontWeight.w700, color: Theme.of(context).colorScheme.onPrimaryContainer, ), ), ); } String _buildSubtitle(String officeName, String status) { final statusLabel = status.toUpperCase(); return '$officeName ยท $statusLabel'; } } List> _staffOptions(List? profiles) { final items = profiles ?? const []; final sorted = [...items] ..sort((a, b) => _profileLabel(a).compareTo(_profileLabel(b))); return [ const DropdownMenuItem(value: null, child: Text('All staff')), ...sorted.map( (profile) => DropdownMenuItem( value: profile.id, child: Text(_profileLabel(profile)), ), ), ]; } String _profileLabel(Profile profile) { return profile.fullName.isNotEmpty ? profile.fullName : profile.id; } List> _taskStatusOptions(List tasks) { final statuses = tasks.map((task) => task.status).toSet().toList()..sort(); return [ const DropdownMenuItem(value: null, child: Text('All statuses')), ...statuses.map( (status) => DropdownMenuItem(value: status, child: Text(status)), ), ]; } List _applyTaskFilters( List tasks, { required Map ticketById, required String subjectQuery, required String? officeId, required String? status, required String? assigneeId, required DateTimeRange? dateRange, }) { final query = subjectQuery.trim().toLowerCase(); return tasks.where((task) { final ticket = task.ticketId == null ? null : ticketById[task.ticketId]; final subject = task.title.isNotEmpty ? task.title : (ticket?.subject ?? 'Task ${task.id}'); if (query.isNotEmpty && !subject.toLowerCase().contains(query) && !task.id.toLowerCase().contains(query)) { return false; } final resolvedOfficeId = ticket?.officeId ?? task.officeId; if (officeId != null && resolvedOfficeId != officeId) { return false; } if (status != null && task.status != status) { return false; } if (assigneeId != null && task.creatorId != assigneeId) { return false; } if (dateRange != null) { final start = DateTime( dateRange.start.year, dateRange.start.month, dateRange.start.day, ); final end = DateTime( dateRange.end.year, dateRange.end.month, dateRange.end.day, 23, 59, 59, ); if (task.createdAt.isBefore(start) || task.createdAt.isAfter(end)) { return false; } } return true; }).toList(); } Map _taskStatusCounts(List tasks) { final counts = {}; for (final task in tasks) { counts.update(task.status, (value) => value + 1, ifAbsent: () => 1); } return counts; } String _formatDateRange(DateTimeRange range) { return '${_formatDate(range.start)} - ${_formatDate(range.end)}'; } String _formatDate(DateTime value) { final year = value.year.toString().padLeft(4, '0'); final month = value.month.toString().padLeft(2, '0'); final day = value.day.toString().padLeft(2, '0'); return '$year-$month-$day'; } class _StatusSummaryRow extends StatelessWidget { const _StatusSummaryRow({required this.counts}); final Map counts; @override Widget build(BuildContext context) { if (counts.isEmpty) { return const SizedBox.shrink(); } final entries = counts.entries.toList() ..sort((a, b) => a.key.compareTo(b.key)); return LayoutBuilder( builder: (context, constraints) { final maxWidth = constraints.maxWidth; final maxPerRow = maxWidth >= 1000 ? 4 : maxWidth >= 720 ? 3 : maxWidth >= 480 ? 2 : entries.length; final perRow = entries.length < maxPerRow ? entries.length : maxPerRow; final spacing = maxWidth < 480 ? 8.0 : 12.0; final itemWidth = perRow == 0 ? maxWidth : (maxWidth - spacing * (perRow - 1)) / perRow; return Wrap( spacing: spacing, runSpacing: spacing, children: [ for (final entry in entries) SizedBox( width: itemWidth, child: _StatusSummaryCard( status: entry.key, count: entry.value, ), ), ], ); }, ); } } class _StatusSummaryCard extends StatelessWidget { const _StatusSummaryCard({required this.status, required this.count}); final String status; final int count; @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; final background = switch (status) { 'critical' => scheme.errorContainer, 'queued' => scheme.surfaceContainerHighest, 'in_progress' => scheme.secondaryContainer, 'completed' => scheme.primaryContainer, _ => scheme.surfaceContainerHigh, }; final foreground = switch (status) { 'critical' => scheme.onErrorContainer, 'queued' => scheme.onSurfaceVariant, 'in_progress' => scheme.onSecondaryContainer, 'completed' => scheme.onPrimaryContainer, _ => scheme.onSurfaceVariant, }; return Card( color: background, elevation: 0, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( status.toUpperCase(), style: Theme.of(context).textTheme.labelSmall?.copyWith( color: foreground, fontWeight: FontWeight.w600, letterSpacing: 0.4, ), ), const SizedBox(height: 6), Text( count.toString(), style: Theme.of(context).textTheme.headlineSmall?.copyWith( color: foreground, fontWeight: FontWeight.w700, ), ), ], ), ), ); } } String _assignedAgent(Map profileById, String? userId) { if (userId == null || userId.isEmpty) { return 'Unassigned'; } final profile = profileById[userId]; if (profile == null) { return userId; } return profile.fullName.isNotEmpty ? profile.fullName : profile.id; } String _formatTimestamp(DateTime value) { final year = value.year.toString().padLeft(4, '0'); final month = value.month.toString().padLeft(2, '0'); final day = value.day.toString().padLeft(2, '0'); final hour = value.hour.toString().padLeft(2, '0'); final minute = value.minute.toString().padLeft(2, '0'); return '$year-$month-$day $hour:$minute'; } class _StatusBadge extends StatelessWidget { const _StatusBadge({required this.status}); final String status; @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; final background = switch (status) { 'critical' => scheme.errorContainer, 'queued' => scheme.surfaceContainerHighest, 'in_progress' => scheme.secondaryContainer, 'completed' => scheme.primaryContainer, _ => scheme.surfaceContainerHighest, }; final foreground = switch (status) { 'critical' => scheme.onErrorContainer, 'queued' => scheme.onSurfaceVariant, 'in_progress' => scheme.onSecondaryContainer, 'completed' => scheme.onPrimaryContainer, _ => scheme.onSurfaceVariant, }; return Badge( backgroundColor: background, label: Text( status.toUpperCase(), style: Theme.of(context).textTheme.labelSmall?.copyWith( color: foreground, fontWeight: FontWeight.w600, letterSpacing: 0.3, ), ), ); } }