Skeleton loading

This commit is contained in:
2026-02-28 21:47:24 +08:00
parent c5e859ad88
commit d3239d8c76
9 changed files with 2755 additions and 2285 deletions
File diff suppressed because it is too large Load Diff
+368 -338
View File
@@ -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(),
],
);
}