1310 lines
51 KiB
Dart
1310 lines
51 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import '../../theme/m3_motion.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:flutter_typeahead/flutter_typeahead.dart';
|
|
import 'package:tasq/utils/app_time.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/task_assignment.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/realtime_controller.dart';
|
|
import '../../providers/typing_provider.dart';
|
|
import '../../widgets/mono_text.dart';
|
|
import '../../widgets/reconnect_overlay.dart';
|
|
import 'package:skeletonizer/skeletonizer.dart';
|
|
import '../../widgets/responsive_body.dart';
|
|
import '../../widgets/tasq_adaptive_list.dart';
|
|
import '../../widgets/typing_dots.dart';
|
|
import '../../theme/app_surfaces.dart';
|
|
import '../../utils/snackbar.dart';
|
|
import '../../widgets/app_page_header.dart';
|
|
import '../../widgets/app_state_view.dart';
|
|
import '../../utils/subject_suggestions.dart';
|
|
import '../../widgets/gemini_button.dart';
|
|
import '../../widgets/gemini_animated_text_field.dart';
|
|
import 'it_job_checklist_tab.dart';
|
|
|
|
// request metadata options used in task creation/editing dialogs
|
|
const List<String> _requestTypeOptions = [
|
|
'Install',
|
|
'Repair',
|
|
'Upgrade',
|
|
'Replace',
|
|
'Other',
|
|
];
|
|
|
|
const List<String> _requestCategoryOptions = [
|
|
'Software',
|
|
'Hardware',
|
|
'Network',
|
|
];
|
|
|
|
class TasksListScreen extends ConsumerStatefulWidget {
|
|
const TasksListScreen({super.key});
|
|
|
|
@override
|
|
ConsumerState<TasksListScreen> createState() => _TasksListScreenState();
|
|
}
|
|
|
|
class _TasksListScreenState extends ConsumerState<TasksListScreen>
|
|
with TickerProviderStateMixin {
|
|
final TextEditingController _subjectController = TextEditingController();
|
|
final TextEditingController _taskNumberController = TextEditingController();
|
|
String? _selectedOfficeId;
|
|
String? _selectedStatus;
|
|
String? _selectedAssigneeId;
|
|
DateTimeRange? _selectedDateRange;
|
|
late TabController _tabController;
|
|
bool _showItJobTab = false;
|
|
bool _tabInited = false;
|
|
|
|
@override
|
|
void dispose() {
|
|
_subjectController.dispose();
|
|
_taskNumberController.dispose();
|
|
_tabController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_tabController = TabController(length: 2, vsync: this);
|
|
_tabController.addListener(() {
|
|
if (mounted) setState(() {});
|
|
});
|
|
}
|
|
|
|
void _ensureTabController(bool shouldShowItJobTab) {
|
|
if (_tabInited && _showItJobTab == shouldShowItJobTab) return;
|
|
final oldIndex = _tabController.index;
|
|
_tabController.removeListener(_onTabChange);
|
|
_tabController.dispose();
|
|
_tabController = TabController(
|
|
length: shouldShowItJobTab ? 3 : 2,
|
|
vsync: this,
|
|
initialIndex: oldIndex.clamp(0, shouldShowItJobTab ? 2 : 1),
|
|
);
|
|
_tabController.addListener(_onTabChange);
|
|
_tabInited = true;
|
|
_showItJobTab = shouldShowItJobTab;
|
|
setState(() {});
|
|
}
|
|
|
|
void _onTabChange() {
|
|
if (mounted) setState(() {});
|
|
}
|
|
|
|
bool get _hasTaskFilters {
|
|
return _subjectController.text.trim().isNotEmpty ||
|
|
_taskNumberController.text.trim().isNotEmpty ||
|
|
_selectedOfficeId != null ||
|
|
_selectedStatus != null ||
|
|
(_tabController.index == 1 && _selectedAssigneeId != null) ||
|
|
_selectedDateRange != null;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final tasksAsync = ref.watch(tasksProvider);
|
|
ref.watch(tasksQueryProvider);
|
|
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 assignmentsAsync = ref.watch(taskAssignmentsProvider);
|
|
final realtime = ref.watch(realtimeControllerProvider);
|
|
|
|
// Show skeleton only on initial load (no previous data) or when the
|
|
// tasks channel is recovering. Use per-channel check so unrelated
|
|
// channel issues (e.g. notifications) don't skeleton the tasks list.
|
|
final showSkeleton =
|
|
realtime.isChannelRecovering('tasks') ||
|
|
realtime.isChannelRecovering('task_assignments') ||
|
|
(!tasksAsync.hasValue && tasksAsync.isLoading) ||
|
|
(!ticketsAsync.hasValue && ticketsAsync.isLoading) ||
|
|
(!officesAsync.hasValue && officesAsync.isLoading) ||
|
|
(!profilesAsync.hasValue && profilesAsync.isLoading) ||
|
|
(!assignmentsAsync.hasValue && assignmentsAsync.isLoading) ||
|
|
(!profileAsync.hasValue && profileAsync.isLoading);
|
|
final effectiveShowSkeleton = showSkeleton;
|
|
|
|
final canCreate = profileAsync.maybeWhen(
|
|
data: (profile) =>
|
|
profile != null &&
|
|
(profile.role == 'admin' ||
|
|
profile.role == 'programmer' ||
|
|
profile.role == 'dispatcher' ||
|
|
profile.role == 'it_staff'),
|
|
orElse: () => false,
|
|
);
|
|
final role = profileAsync.valueOrNull?.role ?? '';
|
|
final shouldShowItJobTab =
|
|
role == 'admin' || role == 'dispatcher' || role == 'it_staff';
|
|
final isAdminOrDispatcher = role == 'admin' || role == 'dispatcher';
|
|
if (!_tabInited || _showItJobTab != shouldShowItJobTab) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) _ensureTabController(shouldShowItJobTab);
|
|
});
|
|
}
|
|
|
|
final ticketById = <String, Ticket>{
|
|
for (final ticket in ticketsAsync.valueOrNull ?? <Ticket>[])
|
|
ticket.id: ticket,
|
|
};
|
|
final officeById = <String, Office>{
|
|
for (final office in officesAsync.valueOrNull ?? <Office>[])
|
|
office.id: office,
|
|
};
|
|
final profileById = <String, Profile>{
|
|
for (final profile in profilesAsync.valueOrNull ?? <Profile>[])
|
|
profile.id: profile,
|
|
};
|
|
|
|
return Stack(
|
|
children: [
|
|
ResponsiveBody(
|
|
maxWidth: double.infinity,
|
|
child: Skeletonizer(
|
|
enabled: effectiveShowSkeleton,
|
|
// Always render the full layout structure using valueOrNull so
|
|
// that Skeletonizer has real widget shapes to shimmer. This
|
|
// avoids the blank flash caused by the old `.when(loading:
|
|
// () => SizedBox.shrink())` approach and keeps previous data
|
|
// visible during provider refreshes.
|
|
child: Builder(
|
|
builder: (context) {
|
|
// Show error only when there is genuinely no data.
|
|
if (tasksAsync.hasError && !tasksAsync.hasValue) {
|
|
return AppErrorView(
|
|
error: tasksAsync.error!,
|
|
title: 'Could not load tasks',
|
|
onRetry: () => ref.invalidate(tasksProvider),
|
|
);
|
|
}
|
|
|
|
final tasks = tasksAsync.valueOrNull ?? <Task>[];
|
|
final offices = officesAsync.valueOrNull ?? <Office>[];
|
|
final officesSorted = List<Office>.from(offices)
|
|
..sort(
|
|
(a, b) =>
|
|
a.name.toLowerCase().compareTo(b.name.toLowerCase()),
|
|
);
|
|
final officeOptions = <DropdownMenuItem<String?>>[
|
|
const DropdownMenuItem<String?>(
|
|
value: null,
|
|
child: Text('All offices'),
|
|
),
|
|
...officesSorted.map(
|
|
(office) => DropdownMenuItem<String?>(
|
|
value: office.id,
|
|
child: Text(office.name),
|
|
),
|
|
),
|
|
];
|
|
final staffOptions = _staffOptions(profilesAsync.valueOrNull);
|
|
final statusOptions = _taskStatusOptions(tasks);
|
|
|
|
// derive latest assignee per task from task assignments stream
|
|
final assignments =
|
|
assignmentsAsync.valueOrNull ?? <TaskAssignment>[];
|
|
final assignmentsByTask = <String, TaskAssignment>{};
|
|
for (final a in assignments) {
|
|
final current = assignmentsByTask[a.taskId];
|
|
if (current == null ||
|
|
a.createdAt.isAfter(current.createdAt)) {
|
|
assignmentsByTask[a.taskId] = a;
|
|
}
|
|
}
|
|
final latestAssigneeByTaskId = <String, String?>{};
|
|
for (final entry in assignmentsByTask.entries) {
|
|
latestAssigneeByTaskId[entry.key] = entry.value.userId;
|
|
}
|
|
|
|
// Track ALL assigned users per task (not just latest) for "My Tasks" filtering
|
|
final assignedUsersByTaskId = <String, Set<String>>{};
|
|
for (final a in assignments) {
|
|
assignedUsersByTaskId
|
|
.putIfAbsent(a.taskId, () => <String>{})
|
|
.add(a.userId);
|
|
}
|
|
|
|
final filteredTasks = _applyTaskFilters(
|
|
tasks,
|
|
ticketById: ticketById,
|
|
subjectQuery: _subjectController.text,
|
|
taskNumber: _taskNumberController.text,
|
|
officeId: _selectedOfficeId,
|
|
status: _selectedStatus,
|
|
assigneeId: _selectedAssigneeId,
|
|
dateRange: _selectedDateRange,
|
|
latestAssigneeByTaskId: latestAssigneeByTaskId,
|
|
);
|
|
|
|
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<String?>(
|
|
isExpanded: true,
|
|
key: ValueKey(_selectedOfficeId),
|
|
initialValue: _selectedOfficeId,
|
|
items: officeOptions,
|
|
onChanged: (value) =>
|
|
setState(() => _selectedOfficeId = value),
|
|
decoration: const InputDecoration(labelText: 'Office'),
|
|
),
|
|
),
|
|
SizedBox(
|
|
width: 160,
|
|
child: TextField(
|
|
controller: _taskNumberController,
|
|
onChanged: (_) => setState(() {}),
|
|
decoration: const InputDecoration(
|
|
labelText: 'Task #',
|
|
prefixIcon: Icon(Icons.filter_alt),
|
|
),
|
|
),
|
|
),
|
|
if (_tabController.index == 1)
|
|
SizedBox(
|
|
width: 220,
|
|
child: DropdownButtonFormField<String?>(
|
|
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<String?>(
|
|
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'
|
|
: AppTime.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'),
|
|
),
|
|
],
|
|
);
|
|
|
|
// reusable helper for rendering a list given a subset of tasks
|
|
Widget makeList(List<Task> tasksList) {
|
|
final summary = _StatusSummaryRow(
|
|
counts: _taskStatusCounts(tasksList),
|
|
);
|
|
return TasQAdaptiveList<Task>(
|
|
items: tasksList,
|
|
onRowTap: (task) => context.go('/tasks/${task.id}'),
|
|
summaryDashboard: summary,
|
|
filterHeader: filterHeader,
|
|
skeletonMode: effectiveShowSkeleton,
|
|
onRequestRefresh: () {
|
|
// For server-side pagination, update the query provider
|
|
ref.read(tasksQueryProvider.notifier).state =
|
|
const TaskQuery(offset: 0, limit: 50);
|
|
},
|
|
onPageChanged: null,
|
|
isLoading: false,
|
|
columns: [
|
|
TasQColumn<Task>(
|
|
header: 'Task #',
|
|
technical: true,
|
|
cellBuilder: (context, task) =>
|
|
Text(task.taskNumber ?? task.id),
|
|
),
|
|
TasQColumn<Task>(
|
|
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<Task>(
|
|
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<Task>(
|
|
header: 'Assigned Agent',
|
|
cellBuilder: (context, task) {
|
|
final assigneeId = latestAssigneeByTaskId[task.id];
|
|
return Text(_assignedAgent(profileById, assigneeId));
|
|
},
|
|
),
|
|
TasQColumn<Task>(
|
|
header: 'Status',
|
|
cellBuilder: (context, task) => Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
_StatusBadge(status: task.status),
|
|
if (task.status == 'completed' &&
|
|
task.hasIncompleteDetails) ...[
|
|
const SizedBox(width: 4),
|
|
const Icon(
|
|
Icons.warning_amber_rounded,
|
|
size: 16,
|
|
color: Colors.orange,
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
TasQColumn<Task>(
|
|
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,
|
|
latestAssigneeByTaskId[task.id],
|
|
);
|
|
final subtitle = _buildSubtitle(officeName, task.status);
|
|
final hasMention = _hasTaskMention(
|
|
notificationsAsync,
|
|
task,
|
|
);
|
|
final typingState = ref.watch(
|
|
typingIndicatorProvider(task.id),
|
|
);
|
|
final showTyping = typingState.userIds.isNotEmpty;
|
|
|
|
return M3PressScale(
|
|
onTap: () => context.go('/tasks/${task.id}'),
|
|
child: Card(
|
|
child: ListTile(
|
|
leading: _buildQueueBadge(context, task),
|
|
dense: true,
|
|
visualDensity: VisualDensity.compact,
|
|
title: Text(
|
|
task.title.isNotEmpty
|
|
? task.title
|
|
: (ticket?.subject ??
|
|
'Task ${task.taskNumber ?? task.id}'),
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
subtitle: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(subtitle),
|
|
const SizedBox(height: 2),
|
|
Text('Assigned: $assigned'),
|
|
const SizedBox(height: 4),
|
|
MonoText('ID ${task.taskNumber ?? task.id}'),
|
|
const SizedBox(height: 2),
|
|
Text(_formatTimestamp(task.createdAt)),
|
|
],
|
|
),
|
|
trailing: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
_StatusBadge(status: task.status),
|
|
if (task.status == 'completed' &&
|
|
task.hasIncompleteDetails) ...[
|
|
const SizedBox(width: 4),
|
|
const Icon(
|
|
Icons.warning_amber_rounded,
|
|
size: 16,
|
|
color: Colors.orange,
|
|
),
|
|
],
|
|
if (showTyping) ...[
|
|
const SizedBox(width: 6),
|
|
TypingDots(
|
|
size: 6,
|
|
color:
|
|
Theme.of(context).colorScheme.primary,
|
|
),
|
|
],
|
|
if (hasMention)
|
|
Padding(
|
|
padding: const EdgeInsets.only(left: 8),
|
|
child: Icon(
|
|
Icons.circle,
|
|
size: 10,
|
|
color:
|
|
Theme.of(context).colorScheme.error,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
final currentUserId = profileAsync.valueOrNull?.id;
|
|
final myTasks = currentUserId == null
|
|
? <Task>[]
|
|
: filteredTasks
|
|
.where(
|
|
(t) =>
|
|
assignedUsersByTaskId[t.id]?.contains(
|
|
currentUserId,
|
|
) ??
|
|
false,
|
|
)
|
|
.toList();
|
|
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.max,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
const AppPageHeader(
|
|
title: 'Tasks',
|
|
subtitle: 'Work items assigned to your team',
|
|
),
|
|
Expanded(
|
|
child: Column(
|
|
children: [
|
|
TabBar(
|
|
controller: _tabController,
|
|
tabs: [
|
|
const Tab(text: 'My Tasks'),
|
|
const Tab(text: 'All Tasks'),
|
|
if (_showItJobTab)
|
|
const Tab(text: 'IT Job Checklist'),
|
|
],
|
|
),
|
|
Expanded(
|
|
child: TabBarView(
|
|
controller: _tabController,
|
|
children: [
|
|
myTasks.isEmpty && !effectiveShowSkeleton
|
|
? AppEmptyView(
|
|
icon: Icons.task_outlined,
|
|
title: _hasTaskFilters
|
|
? 'No matching tasks'
|
|
: 'No tasks assigned to you',
|
|
subtitle: _hasTaskFilters
|
|
? 'Try adjusting your filters.'
|
|
: 'Tasks assigned to you will appear here.',
|
|
)
|
|
: makeList(myTasks),
|
|
filteredTasks.isEmpty && !effectiveShowSkeleton
|
|
? AppEmptyView(
|
|
icon: Icons.task_alt_outlined,
|
|
title: _hasTaskFilters
|
|
? 'No matching tasks'
|
|
: 'No tasks yet',
|
|
subtitle: _hasTaskFilters
|
|
? 'Try adjusting your filters.'
|
|
: 'Tasks created for your team will appear here.',
|
|
)
|
|
: makeList(filteredTasks),
|
|
if (_showItJobTab)
|
|
ItJobChecklistTab(
|
|
isAdminView: isAdminOrDispatcher),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
if (_showItJobTab && _tabController.index == 2 && isAdminOrDispatcher)
|
|
Positioned(
|
|
right: 16,
|
|
bottom: 16,
|
|
child: SafeArea(
|
|
child: M3ExpandedFab(
|
|
heroTag: 'notify_all_fab',
|
|
onPressed: () => _notifyAllPending(ref),
|
|
icon: const Icon(Icons.notifications_active),
|
|
label: const Text('Notify All Pending'),
|
|
),
|
|
),
|
|
)
|
|
else if (canCreate)
|
|
Positioned(
|
|
right: 16,
|
|
bottom: 16,
|
|
child: SafeArea(
|
|
child: M3ExpandedFab(
|
|
onPressed: () => _showCreateTaskDialog(context, ref),
|
|
icon: const Icon(Icons.add),
|
|
label: const Text('New Task'),
|
|
),
|
|
),
|
|
),
|
|
const ReconnectIndicator(),
|
|
],
|
|
);
|
|
}
|
|
|
|
Future<void> _notifyAllPending(WidgetRef ref) async {
|
|
final tasks = ref.read(tasksProvider).valueOrNull ?? [];
|
|
final assignments = ref.read(taskAssignmentsProvider).valueOrNull ?? [];
|
|
|
|
// All completed tasks with IT Job not yet submitted
|
|
final pending = tasks
|
|
.where((t) => t.status == 'completed' && !t.itJobPrinted)
|
|
.toList();
|
|
|
|
if (pending.isEmpty) {
|
|
if (mounted) showSuccessSnackBar(context, 'No pending IT Jobs to notify');
|
|
return;
|
|
}
|
|
|
|
// One notification per unique assignee (not per task)
|
|
final userIds = <String>{};
|
|
for (final task in pending) {
|
|
for (final a in assignments.where((a) => a.taskId == task.id)) {
|
|
userIds.add(a.userId);
|
|
}
|
|
}
|
|
|
|
if (userIds.isEmpty) {
|
|
if (mounted) showErrorSnackBar(context, 'No assigned staff found');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
final currentUserId = ref.read(currentUserIdProvider);
|
|
// Single generic notification per user — no task_id to avoid flooding
|
|
await ref.read(notificationsControllerProvider).createNotification(
|
|
userIds: userIds.toList(),
|
|
type: 'it_job_reminder',
|
|
actorId: currentUserId ?? '',
|
|
pushTitle: 'IT Job Reminder',
|
|
pushBody:
|
|
'You have pending IT Job submissions. Please submit your printed copies to the dispatcher.',
|
|
pushData: {'navigate_to': '/tasks'},
|
|
);
|
|
if (mounted) {
|
|
showSuccessSnackBar(
|
|
context,
|
|
'Reminder sent to ${userIds.length} staff member(s)',
|
|
);
|
|
}
|
|
} catch (e) {
|
|
if (mounted) showErrorSnackBar(context, 'Failed to send: $e');
|
|
}
|
|
}
|
|
|
|
Future<void> _showCreateTaskDialog(
|
|
BuildContext context,
|
|
WidgetRef ref,
|
|
) async {
|
|
final titleController = TextEditingController();
|
|
final descriptionController = TextEditingController();
|
|
final existingSubjects = <String>[
|
|
...((ref.read(tasksProvider).valueOrNull ?? const <Task>[]).map(
|
|
(task) => task.title,
|
|
)),
|
|
...((ref.read(ticketsProvider).valueOrNull ?? const <Ticket>[]).map(
|
|
(ticket) => ticket.subject,
|
|
)),
|
|
];
|
|
String? selectedOfficeId;
|
|
String? selectedRequestType;
|
|
String? requestTypeOther;
|
|
String? selectedRequestCategory;
|
|
|
|
var showTitleGemini = false;
|
|
Timer? titleTypingTimer;
|
|
|
|
await m3ShowDialog<void>(
|
|
context: context,
|
|
builder: (dialogContext) {
|
|
bool saving = false;
|
|
bool titleProcessing = false;
|
|
bool descProcessing = false;
|
|
bool titleDeepSeek = false;
|
|
bool descDeepSeek = false;
|
|
final officesAsync = ref.watch(officesProvider);
|
|
return StatefulBuilder(
|
|
builder: (context, setState) {
|
|
return AlertDialog(
|
|
shape: AppSurfaces.of(context).dialogShape,
|
|
title: const Text('Create Task'),
|
|
content: SizedBox(
|
|
width: 360,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: GeminiAnimatedBorder(
|
|
isProcessing: titleProcessing,
|
|
useDeepSeekColors: titleDeepSeek,
|
|
child: KeyboardListener(
|
|
focusNode: FocusNode(),
|
|
onKeyEvent: (event) {
|
|
if (event is! KeyDownEvent &&
|
|
event is! KeyRepeatEvent) {
|
|
return;
|
|
}
|
|
if (event.character == null ||
|
|
event.character!.isEmpty) {
|
|
return;
|
|
}
|
|
titleTypingTimer?.cancel();
|
|
if (showTitleGemini) {
|
|
setState(() => showTitleGemini = false);
|
|
}
|
|
titleTypingTimer = Timer(
|
|
const Duration(milliseconds: 700),
|
|
() {
|
|
if (titleController.text
|
|
.trim()
|
|
.isNotEmpty) {
|
|
setState(() => showTitleGemini = true);
|
|
}
|
|
},
|
|
);
|
|
},
|
|
child: TypeAheadFormField<String>(
|
|
textFieldConfiguration: TextFieldConfiguration(
|
|
controller: titleController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Task title',
|
|
),
|
|
enabled: !saving,
|
|
),
|
|
suggestionsCallback: (pattern) async {
|
|
return SubjectSuggestionEngine.suggest(
|
|
existingSubjects: existingSubjects,
|
|
query: pattern,
|
|
limit: 8,
|
|
);
|
|
},
|
|
itemBuilder: (context, suggestion) => ListTile(
|
|
dense: true,
|
|
title: Text(suggestion),
|
|
),
|
|
onSuggestionSelected: (suggestion) {
|
|
titleTypingTimer?.cancel();
|
|
titleController
|
|
..text = suggestion
|
|
..selection = TextSelection.collapsed(
|
|
offset: suggestion.length,
|
|
);
|
|
setState(() => showTitleGemini = false);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
),
|
|
if (showTitleGemini)
|
|
GeminiButton(
|
|
textController: titleController,
|
|
onTextUpdated: (updatedText) {
|
|
titleTypingTimer?.cancel();
|
|
setState(() {
|
|
titleController.text = updatedText;
|
|
showTitleGemini = false;
|
|
});
|
|
},
|
|
onProcessingStateChanged: (isProcessing) {
|
|
setState(() {
|
|
titleProcessing = isProcessing;
|
|
});
|
|
},
|
|
onProviderChanged: (isDeepSeek) {
|
|
setState(() => titleDeepSeek = isDeepSeek);
|
|
},
|
|
tooltip: 'Improve task title with Gemini',
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: GeminiAnimatedTextField(
|
|
controller: descriptionController,
|
|
labelText: 'Description',
|
|
maxLines: 3,
|
|
enabled: !saving,
|
|
isProcessing: descProcessing,
|
|
useDeepSeekColors: descDeepSeek,
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.only(left: 8.0),
|
|
child: GeminiButton(
|
|
textController: descriptionController,
|
|
onTextUpdated: (updatedText) {
|
|
setState(() {
|
|
descriptionController.text = updatedText;
|
|
});
|
|
},
|
|
onProcessingStateChanged: (isProcessing) {
|
|
setState(() {
|
|
descProcessing = isProcessing;
|
|
});
|
|
},
|
|
onProviderChanged: (isDeepSeek) {
|
|
setState(() => descDeepSeek = isDeepSeek);
|
|
},
|
|
tooltip: 'Improve description with Gemini',
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
officesAsync.when(
|
|
data: (offices) {
|
|
if (offices.isEmpty) {
|
|
return const Text('No offices available.');
|
|
}
|
|
final officesSorted = List<Office>.from(offices)
|
|
..sort(
|
|
(a, b) => a.name.toLowerCase().compareTo(
|
|
b.name.toLowerCase(),
|
|
),
|
|
);
|
|
selectedOfficeId ??= officesSorted.first.id;
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
DropdownButtonFormField<String>(
|
|
initialValue: selectedOfficeId,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Office',
|
|
),
|
|
items: officesSorted
|
|
.map(
|
|
(office) => DropdownMenuItem(
|
|
value: office.id,
|
|
child: Text(office.name),
|
|
),
|
|
)
|
|
.toList(),
|
|
onChanged: saving
|
|
? null
|
|
: (value) => setState(
|
|
() => selectedOfficeId = value,
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
// optional request metadata inputs
|
|
DropdownButtonFormField<String>(
|
|
initialValue: selectedRequestType,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Request type (optional)',
|
|
),
|
|
items: _requestTypeOptions
|
|
.map(
|
|
(t) => DropdownMenuItem(
|
|
value: t,
|
|
child: Text(t),
|
|
),
|
|
)
|
|
.toList(),
|
|
onChanged: saving
|
|
? null
|
|
: (value) => setState(
|
|
() => selectedRequestType = value,
|
|
),
|
|
),
|
|
if (selectedRequestType == 'Other') ...[
|
|
const SizedBox(height: 8),
|
|
TextField(
|
|
decoration: const InputDecoration(
|
|
labelText: 'Please specify',
|
|
),
|
|
onChanged: (v) => requestTypeOther = v,
|
|
),
|
|
],
|
|
const SizedBox(height: 12),
|
|
DropdownButtonFormField<String>(
|
|
initialValue: selectedRequestCategory,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Request category (optional)',
|
|
),
|
|
items: _requestCategoryOptions
|
|
.map(
|
|
(t) => DropdownMenuItem(
|
|
value: t,
|
|
child: Text(t),
|
|
),
|
|
)
|
|
.toList(),
|
|
onChanged: saving
|
|
? null
|
|
: (value) => setState(
|
|
() => selectedRequestCategory = value,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
loading: () => const Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: CircularProgressIndicator(),
|
|
),
|
|
error: (error, _) =>
|
|
Text('Failed to load offices: $error'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: saving
|
|
? null
|
|
: () => Navigator.of(dialogContext).pop(),
|
|
child: const Text('Cancel'),
|
|
),
|
|
FilledButton(
|
|
onPressed: saving
|
|
? null
|
|
: () async {
|
|
final title =
|
|
SubjectSuggestionEngine.normalizeDisplay(
|
|
titleController.text.trim(),
|
|
);
|
|
final description = descriptionController.text.trim();
|
|
final officeId = selectedOfficeId;
|
|
if (title.isEmpty || officeId == null) {
|
|
return;
|
|
}
|
|
setState(() => saving = true);
|
|
await ref
|
|
.read(tasksControllerProvider)
|
|
.createTask(
|
|
title: title,
|
|
description: description,
|
|
officeId: officeId,
|
|
requestType: selectedRequestType,
|
|
requestTypeOther: requestTypeOther,
|
|
requestCategory: selectedRequestCategory,
|
|
);
|
|
if (context.mounted) {
|
|
Navigator.of(dialogContext).pop();
|
|
showSuccessSnackBarGlobal(
|
|
'Task "$title" has been created successfully.',
|
|
);
|
|
}
|
|
},
|
|
child: saving
|
|
? const SizedBox(
|
|
height: 18,
|
|
width: 18,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: const Text('Create'),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
bool _hasTaskMention(
|
|
AsyncValue<List<NotificationItem>> 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(
|
|
AppSurfaces.of(context).compactCardRadius,
|
|
),
|
|
),
|
|
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<DropdownMenuItem<String?>> _staffOptions(List<Profile>? profiles) {
|
|
final items = profiles ?? const <Profile>[];
|
|
final sorted = [...items]
|
|
..sort((a, b) => _profileLabel(a).compareTo(_profileLabel(b)));
|
|
return [
|
|
const DropdownMenuItem<String?>(value: null, child: Text('All staff')),
|
|
...sorted.map(
|
|
(profile) => DropdownMenuItem<String?>(
|
|
value: profile.id,
|
|
child: Text(_profileLabel(profile)),
|
|
),
|
|
),
|
|
];
|
|
}
|
|
|
|
String _profileLabel(Profile profile) {
|
|
return profile.fullName.isNotEmpty ? profile.fullName : profile.id;
|
|
}
|
|
|
|
List<DropdownMenuItem<String?>> _taskStatusOptions(List<Task> tasks) {
|
|
final statuses = tasks.map((task) => task.status).toSet().toList()..sort();
|
|
return [
|
|
const DropdownMenuItem<String?>(value: null, child: Text('All statuses')),
|
|
...statuses.map(
|
|
(status) => DropdownMenuItem<String?>(value: status, child: Text(status)),
|
|
),
|
|
];
|
|
}
|
|
|
|
List<Task> _applyTaskFilters(
|
|
List<Task> tasks, {
|
|
required Map<String, Ticket> ticketById,
|
|
required String subjectQuery,
|
|
required String taskNumber,
|
|
required String? officeId,
|
|
required String? status,
|
|
required String? assigneeId,
|
|
required DateTimeRange? dateRange,
|
|
required Map<String, String?> latestAssigneeByTaskId,
|
|
}) {
|
|
final query = subjectQuery.trim().toLowerCase();
|
|
final tnQuery = taskNumber.trim().toLowerCase();
|
|
return tasks.where((task) {
|
|
if (tnQuery.isNotEmpty &&
|
|
!(task.taskNumber?.toLowerCase().contains(tnQuery) ?? false)) {
|
|
return false;
|
|
}
|
|
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.taskNumber?.toLowerCase().contains(query) ?? false) &&
|
|
!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;
|
|
}
|
|
final currentAssignee = latestAssigneeByTaskId[task.id];
|
|
if (assigneeId != null && currentAssignee != 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<String, int> _taskStatusCounts(List<Task> tasks) {
|
|
final counts = <String, int>{};
|
|
for (final task in tasks) {
|
|
// Do not include cancelled tasks in dashboard summaries
|
|
if (task.status == 'cancelled') continue;
|
|
counts.update(task.status, (value) => value + 1, ifAbsent: () => 1);
|
|
}
|
|
return counts;
|
|
}
|
|
|
|
class _StatusSummaryRow extends StatelessWidget {
|
|
const _StatusSummaryRow({required this.counts});
|
|
|
|
final Map<String, int> 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,
|
|
};
|
|
|
|
// M3 Expressive: filled card with semantic tonal color, no shadow.
|
|
return Card(
|
|
color: background,
|
|
elevation: 0,
|
|
shadowColor: Colors.transparent,
|
|
// summary cards are compact — use compact token for consistent density
|
|
shape: AppSurfaces.of(context).compactShape,
|
|
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<String, Profile> 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,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|