Skeleton loading
This commit is contained in:
+1702
-1590
File diff suppressed because it is too large
Load Diff
@@ -13,8 +13,11 @@ 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';
|
||||
@@ -52,6 +55,7 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
|
||||
String? _selectedAssigneeId;
|
||||
DateTimeRange? _selectedDateRange;
|
||||
late final TabController _tabController;
|
||||
bool _isSwitchingTab = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
@@ -66,8 +70,15 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
|
||||
super.initState();
|
||||
_tabController = TabController(length: 2, vsync: this);
|
||||
_tabController.addListener(() {
|
||||
// rebuild when tab changes so filters shown/hidden update
|
||||
setState(() {});
|
||||
// briefly show a skeleton when switching tabs so the UI can
|
||||
// navigate ahead and avoid a janky synchronous rebuild.
|
||||
if (!_isSwitchingTab) {
|
||||
setState(() => _isSwitchingTab = true);
|
||||
Future.delayed(const Duration(milliseconds: 150), () {
|
||||
if (!mounted) return;
|
||||
setState(() => _isSwitchingTab = false);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -90,6 +101,17 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
|
||||
final notificationsAsync = ref.watch(notificationsProvider);
|
||||
final profilesAsync = ref.watch(profilesProvider);
|
||||
final assignmentsAsync = ref.watch(taskAssignmentsProvider);
|
||||
final realtime = ref.watch(realtimeControllerProvider);
|
||||
|
||||
final showSkeleton =
|
||||
realtime.isConnecting ||
|
||||
tasksAsync.maybeWhen(loading: () => true, orElse: () => false) ||
|
||||
ticketsAsync.maybeWhen(loading: () => true, orElse: () => false) ||
|
||||
officesAsync.maybeWhen(loading: () => true, orElse: () => false) ||
|
||||
profilesAsync.maybeWhen(loading: () => true, orElse: () => false) ||
|
||||
assignmentsAsync.maybeWhen(loading: () => true, orElse: () => false) ||
|
||||
profileAsync.maybeWhen(loading: () => true, orElse: () => false);
|
||||
final effectiveShowSkeleton = showSkeleton || _isSwitchingTab;
|
||||
|
||||
final canCreate = profileAsync.maybeWhen(
|
||||
data: (profile) =>
|
||||
@@ -117,289 +139,226 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
|
||||
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 ?? <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;
|
||||
child: Skeletonizer(
|
||||
enabled: effectiveShowSkeleton,
|
||||
child: tasksAsync.when(
|
||||
data: (tasks) {
|
||||
if (tasks.isEmpty) {
|
||||
return const Center(child: Text('No tasks yet.'));
|
||||
}
|
||||
}
|
||||
final latestAssigneeByTaskId = <String, String?>{};
|
||||
for (final entry in assignmentsByTask.entries) {
|
||||
latestAssigneeByTaskId[entry.key] = entry.value.userId;
|
||||
}
|
||||
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);
|
||||
|
||||
final filteredTasks = _applyTaskFilters(
|
||||
tasks,
|
||||
ticketById: ticketById,
|
||||
subjectQuery: _subjectController.text,
|
||||
taskNumber: _taskNumberController.text,
|
||||
officeId: _selectedOfficeId,
|
||||
status: _selectedStatus,
|
||||
assigneeId: _selectedAssigneeId,
|
||||
dateRange: _selectedDateRange,
|
||||
latestAssigneeByTaskId: latestAssigneeByTaskId,
|
||||
);
|
||||
// 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;
|
||||
}
|
||||
|
||||
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)
|
||||
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(_selectedAssigneeId),
|
||||
initialValue: _selectedAssigneeId,
|
||||
items: staffOptions,
|
||||
key: ValueKey(_selectedOfficeId),
|
||||
initialValue: _selectedOfficeId,
|
||||
items: officeOptions,
|
||||
onChanged: (value) =>
|
||||
setState(() => _selectedAssigneeId = value),
|
||||
setState(() => _selectedOfficeId = value),
|
||||
decoration: const InputDecoration(labelText: 'Office'),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 160,
|
||||
child: TextField(
|
||||
controller: _taskNumberController,
|
||||
onChanged: (_) => setState(() {}),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Assigned staff',
|
||||
labelText: 'Task #',
|
||||
prefixIcon: Icon(Icons.filter_alt),
|
||||
),
|
||||
),
|
||||
),
|
||||
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,
|
||||
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,
|
||||
),
|
||||
],
|
||||
],
|
||||
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'),
|
||||
),
|
||||
),
|
||||
TasQColumn<Task>(
|
||||
header: 'Timestamp',
|
||||
technical: true,
|
||||
cellBuilder: (context, task) =>
|
||||
Text(_formatTimestamp(task.createdAt)),
|
||||
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'),
|
||||
),
|
||||
],
|
||||
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 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}'),
|
||||
),
|
||||
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(
|
||||
// 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),
|
||||
@@ -412,85 +371,155 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
|
||||
color: Colors.orange,
|
||||
),
|
||||
],
|
||||
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}'),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
final currentUserId = profileAsync.valueOrNull?.id;
|
||||
final myTasks = currentUserId == null
|
||||
? <Task>[]
|
||||
: filteredTasks
|
||||
.where(
|
||||
(t) => latestAssigneeByTaskId[t.id] == currentUserId,
|
||||
)
|
||||
.toList();
|
||||
|
||||
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,
|
||||
),
|
||||
TasQColumn<Task>(
|
||||
header: 'Timestamp',
|
||||
technical: true,
|
||||
cellBuilder: (context, task) =>
|
||||
Text(_formatTimestamp(task.createdAt)),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
TabBar(
|
||||
controller: _tabController,
|
||||
tabs: const [
|
||||
Tab(text: 'My Tasks'),
|
||||
Tab(text: 'All Tasks'),
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: _tabController,
|
||||
],
|
||||
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 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}'),
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
makeList(myTasks),
|
||||
makeList(filteredTasks),
|
||||
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)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(left: 8),
|
||||
child: Icon(
|
||||
Icons.circle,
|
||||
size: 10,
|
||||
color: Colors.red,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: () => context.go('/tasks/${task.id}'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
final currentUserId = profileAsync.valueOrNull?.id;
|
||||
final myTasks = currentUserId == null
|
||||
? <Task>[]
|
||||
: filteredTasks
|
||||
.where(
|
||||
(t) =>
|
||||
latestAssigneeByTaskId[t.id] == currentUserId,
|
||||
)
|
||||
.toList();
|
||||
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) =>
|
||||
Center(child: Text('Failed to load tasks: $error')),
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
TabBar(
|
||||
controller: _tabController,
|
||||
tabs: const [
|
||||
Tab(text: 'My Tasks'),
|
||||
Tab(text: 'All Tasks'),
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
makeList(myTasks),
|
||||
makeList(filteredTasks),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
loading: () => const SizedBox.shrink(),
|
||||
error: (error, _) =>
|
||||
Center(child: Text('Failed to load tasks: $error')),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (canCreate)
|
||||
@@ -505,6 +534,7 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
|
||||
),
|
||||
),
|
||||
),
|
||||
const ReconnectOverlay(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user