Skeleton loading

This commit is contained in:
Marc Rejohn Castillano 2026-02-28 21:47:24 +08:00
parent c5e859ad88
commit d3239d8c76
9 changed files with 2755 additions and 2285 deletions

View File

@ -20,15 +20,19 @@ final realtimeControllerProvider = ChangeNotifierProvider<RealtimeController>((
/// connection when the app returns to the foreground or when auth tokens
/// are refreshed.
class RealtimeController extends ChangeNotifier {
RealtimeController(this._client) {
_init();
}
final SupabaseClient _client;
bool isConnecting = false;
bool isFailed = false;
String? lastError;
int attempts = 0;
final int maxAttempts;
bool _disposed = false;
RealtimeController(this._client, {this.maxAttempts = 4}) {
_init();
}
void _init() {
try {
// Listen for auth changes and try to recover the realtime connection
@ -39,7 +43,9 @@ class RealtimeController extends ChangeNotifier {
recoverConnection();
}
});
} catch (_) {}
} catch (e) {
debugPrint('RealtimeController._init error: $e');
}
}
/// Try to reconnect the realtime client using a small exponential backoff.
@ -47,15 +53,15 @@ class RealtimeController extends ChangeNotifier {
if (_disposed) return;
if (isConnecting) return;
isFailed = false;
lastError = null;
isConnecting = true;
notifyListeners();
try {
int attempt = 0;
int maxAttempts = 4;
int delaySeconds = 1;
while (attempt < maxAttempts && !_disposed) {
attempt++;
while (attempts < maxAttempts && !_disposed) {
attempts++;
try {
// Best-effort disconnect then connect so the realtime client picks
// up any refreshed tokens.
@ -82,11 +88,17 @@ class RealtimeController extends ChangeNotifier {
// Give the socket a moment to stabilise.
await Future.delayed(const Duration(seconds: 1));
// Exit early; we don't have a reliable sync API for connection
// state across all platforms, so treat this as a best-effort
// resurrection.
// Success (best-effort). Reset attempt counter and clear failure.
attempts = 0;
isFailed = false;
lastError = null;
break;
} catch (_) {
} catch (e) {
lastError = e.toString();
if (attempts >= maxAttempts) {
isFailed = true;
break;
}
await Future.delayed(Duration(seconds: delaySeconds));
delaySeconds = delaySeconds * 2;
}
@ -99,6 +111,16 @@ class RealtimeController extends ChangeNotifier {
}
}
/// Retry a failed recovery attempt.
Future<void> retry() async {
if (_disposed) return;
attempts = 0;
isFailed = false;
lastError = null;
notifyListeners();
await recoverConnection();
}
@override
void dispose() {
_disposed = true;

View File

@ -10,6 +10,8 @@ import '../../providers/profile_provider.dart';
import '../../providers/tasks_provider.dart';
import '../../providers/tickets_provider.dart';
import '../../widgets/responsive_body.dart';
import '../../providers/realtime_controller.dart';
import 'package:skeletonizer/skeletonizer.dart';
import '../../theme/app_surfaces.dart';
import '../../widgets/mono_text.dart';
import '../../widgets/status_pill.dart';
@ -294,97 +296,150 @@ class DashboardScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ResponsiveBody(
child: LayoutBuilder(
builder: (context, constraints) {
final sections = <Widget>[
const SizedBox(height: 16),
_sectionTitle(context, 'IT Staff Pulse'),
const _StaffTable(),
const SizedBox(height: 20),
_sectionTitle(context, 'Core Daily KPIs'),
_cardGrid(context, [
_MetricCard(
title: 'New tickets today',
valueBuilder: (metrics) => metrics.newTicketsToday.toString(),
),
_MetricCard(
title: 'Closed today',
valueBuilder: (metrics) => metrics.closedToday.toString(),
),
_MetricCard(
title: 'Open tickets',
valueBuilder: (metrics) => metrics.openTickets.toString(),
),
]),
const SizedBox(height: 20),
_sectionTitle(context, 'Task Flow'),
_cardGrid(context, [
_MetricCard(
title: 'Tasks created',
valueBuilder: (metrics) => metrics.tasksCreatedToday.toString(),
),
_MetricCard(
title: 'Tasks completed',
valueBuilder: (metrics) =>
metrics.tasksCompletedToday.toString(),
),
_MetricCard(
title: 'Open tasks',
valueBuilder: (metrics) => metrics.openTasks.toString(),
),
]),
const SizedBox(height: 20),
_sectionTitle(context, 'TAT / Response'),
_cardGrid(context, [
_MetricCard(
title: 'Avg response',
valueBuilder: (metrics) => _formatDuration(metrics.avgResponse),
),
_MetricCard(
title: 'Avg triage',
valueBuilder: (metrics) => _formatDuration(metrics.avgTriage),
),
_MetricCard(
title: 'Longest response',
valueBuilder: (metrics) =>
_formatDuration(metrics.longestResponse),
),
]),
];
final realtime = ProviderScope.containerOf(
context,
).read(realtimeControllerProvider);
final content = Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 16, bottom: 8),
child: Align(
alignment: Alignment.center,
child: Text(
'Dashboard',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
return ResponsiveBody(
child: Skeletonizer(
enabled: realtime.isConnecting,
child: LayoutBuilder(
builder: (context, constraints) {
final sections = <Widget>[
const SizedBox(height: 16),
_sectionTitle(context, 'IT Staff Pulse'),
const _StaffTable(),
const SizedBox(height: 20),
_sectionTitle(context, 'Core Daily KPIs'),
_cardGrid(context, [
_MetricCard(
title: 'New tickets today',
valueBuilder: (metrics) => metrics.newTicketsToday.toString(),
),
_MetricCard(
title: 'Closed today',
valueBuilder: (metrics) => metrics.closedToday.toString(),
),
_MetricCard(
title: 'Open tickets',
valueBuilder: (metrics) => metrics.openTickets.toString(),
),
]),
const SizedBox(height: 20),
_sectionTitle(context, 'Task Flow'),
_cardGrid(context, [
_MetricCard(
title: 'Tasks created',
valueBuilder: (metrics) =>
metrics.tasksCreatedToday.toString(),
),
_MetricCard(
title: 'Tasks completed',
valueBuilder: (metrics) =>
metrics.tasksCompletedToday.toString(),
),
_MetricCard(
title: 'Open tasks',
valueBuilder: (metrics) => metrics.openTasks.toString(),
),
]),
const SizedBox(height: 20),
_sectionTitle(context, 'TAT / Response'),
_cardGrid(context, [
_MetricCard(
title: 'Avg response',
valueBuilder: (metrics) =>
_formatDuration(metrics.avgResponse),
),
_MetricCard(
title: 'Avg triage',
valueBuilder: (metrics) => _formatDuration(metrics.avgTriage),
),
_MetricCard(
title: 'Longest response',
valueBuilder: (metrics) =>
_formatDuration(metrics.longestResponse),
),
]),
];
final content = Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 16, bottom: 8),
child: Align(
alignment: Alignment.center,
child: Text(
'Dashboard',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
),
),
const _DashboardStatusBanner(),
...sections,
],
);
const _DashboardStatusBanner(),
...sections,
],
);
return SingleChildScrollView(
padding: const EdgeInsets.only(bottom: 24),
child: Center(
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: constraints.maxHeight),
child: content,
),
),
);
},
return Stack(
children: [
SingleChildScrollView(
padding: const EdgeInsets.only(bottom: 24),
child: Center(
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: constraints.maxHeight,
),
child: content,
),
),
),
if (realtime.isConnecting)
Positioned.fill(
child: AbsorbPointer(
absorbing: true,
child: Container(
color: Theme.of(
context,
).colorScheme.surface.withAlpha((0.35 * 255).round()),
alignment: Alignment.topCenter,
padding: const EdgeInsets.only(top: 36),
child: SizedBox(
width: 280,
child: Card(
elevation: 4,
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Row(
mainAxisSize: MainAxisSize.min,
children: const [
SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
),
),
SizedBox(width: 12),
Expanded(
child: Text('Reconnecting realtime…'),
),
],
),
),
),
),
),
),
),
],
);
},
),
),
);
}

File diff suppressed because it is too large Load Diff

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

View File

@ -10,8 +10,11 @@ import '../../models/ticket.dart';
import '../../providers/notifications_provider.dart';
import '../../providers/profile_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';
@ -30,6 +33,7 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
String? _selectedOfficeId;
String? _selectedStatus;
DateTimeRange? _selectedDateRange;
bool _isInitial = true;
@override
void dispose() {
@ -50,258 +54,280 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
final officesAsync = ref.watch(officesProvider);
final notificationsAsync = ref.watch(notificationsProvider);
final profilesAsync = ref.watch(profilesProvider);
final realtime = ref.watch(realtimeControllerProvider);
final showSkeleton =
realtime.isConnecting ||
ticketsAsync.maybeWhen(loading: () => true, orElse: () => false) ||
officesAsync.maybeWhen(loading: () => true, orElse: () => false) ||
profilesAsync.maybeWhen(loading: () => true, orElse: () => false) ||
notificationsAsync.maybeWhen(loading: () => true, orElse: () => false);
if (_isInitial) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
setState(() => _isInitial = false);
});
}
final effectiveShowSkeleton = showSkeleton || _isInitial;
return Stack(
children: [
ResponsiveBody(
maxWidth: double.infinity,
child: ticketsAsync.when(
data: (tickets) {
if (tickets.isEmpty) {
return const Center(child: Text('No tickets yet.'));
}
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,
};
final unreadByTicketId = _unreadByTicketId(notificationsAsync);
final offices = officesAsync.valueOrNull ?? <Office>[];
final officesSorted = List<Office>.from(offices)
..sort(
(a, b) =>
a.name.toLowerCase().compareTo(b.name.toLowerCase()),
child: Skeletonizer(
enabled: effectiveShowSkeleton,
child: ticketsAsync.when(
data: (tickets) {
if (tickets.isEmpty) {
return const Center(child: Text('No tickets yet.'));
}
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,
};
final unreadByTicketId = _unreadByTicketId(notificationsAsync);
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 statusOptions = _ticketStatusOptions(tickets);
final filteredTickets = _applyTicketFilters(
tickets,
subjectQuery: _subjectController.text,
officeId: _selectedOfficeId,
status: _selectedStatus,
dateRange: _selectedDateRange,
);
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 statusOptions = _ticketStatusOptions(tickets);
final filteredTickets = _applyTicketFilters(
tickets,
subjectQuery: _subjectController.text,
officeId: _selectedOfficeId,
status: _selectedStatus,
dateRange: _selectedDateRange,
);
final summaryDashboard = _StatusSummaryRow(
counts: _statusCounts(filteredTickets),
);
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: 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 (_hasTicketFilters)
TextButton.icon(
onPressed: () => setState(() {
_subjectController.clear();
_selectedOfficeId = null;
_selectedStatus = null;
_selectedDateRange = null;
}),
icon: const Icon(Icons.close),
label: const Text('Clear'),
),
],
);
final listBody = TasQAdaptiveList<Ticket>(
items: filteredTickets,
onRowTap: (ticket) => context.go('/tickets/${ticket.id}'),
summaryDashboard: summaryDashboard,
filterHeader: filterHeader,
onRequestRefresh: () {
// For server-side pagination, update the query provider
// This will trigger a reload with new pagination parameters
ref.read(ticketsQueryProvider.notifier).state =
const TicketQuery(offset: 0, limit: 50);
},
onPageChanged: (firstRow) {
ref
.read(ticketsQueryProvider.notifier)
.update((q) => q.copyWith(offset: firstRow));
},
isLoading: false,
columns: [
TasQColumn<Ticket>(
header: 'Ticket ID',
technical: true,
cellBuilder: (context, ticket) => Text(ticket.id),
),
TasQColumn<Ticket>(
header: 'Subject',
cellBuilder: (context, ticket) => Text(ticket.subject),
),
TasQColumn<Ticket>(
header: 'Office',
cellBuilder: (context, ticket) => Text(
officeById[ticket.officeId]?.name ?? ticket.officeId,
),
),
TasQColumn<Ticket>(
header: 'Filed by',
cellBuilder: (context, ticket) =>
Text(_assignedAgent(profileById, ticket.creatorId)),
),
TasQColumn<Ticket>(
header: 'Status',
cellBuilder: (context, ticket) =>
_StatusBadge(status: ticket.status),
),
TasQColumn<Ticket>(
header: 'Timestamp',
technical: true,
cellBuilder: (context, ticket) =>
Text(_formatTimestamp(ticket.createdAt)),
),
],
mobileTileBuilder: (context, ticket, actions) {
final officeName =
officeById[ticket.officeId]?.name ?? ticket.officeId;
final assigned = _assignedAgent(
profileById,
ticket.creatorId,
);
final hasMention = unreadByTicketId[ticket.id] == true;
final typingState = ref.watch(
typingIndicatorProvider(ticket.id),
);
final showTyping = typingState.userIds.isNotEmpty;
return Card(
child: ListTile(
leading: const Icon(Icons.confirmation_number_outlined),
dense: true,
visualDensity: VisualDensity.compact,
title: Text(ticket.subject),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(officeName),
const SizedBox(height: 2),
Text('Filed by: $assigned'),
const SizedBox(height: 4),
MonoText('ID ${ticket.id}'),
const SizedBox(height: 2),
Text(_formatTimestamp(ticket.createdAt)),
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
_StatusBadge(status: ticket.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('/tickets/${ticket.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(
'Tickets',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
final summaryDashboard = _StatusSummaryRow(
counts: _statusCounts(filteredTickets),
);
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),
),
),
),
),
Expanded(child: listBody),
],
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) =>
Center(child: Text('Failed to load tickets: $error')),
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: 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 (_hasTicketFilters)
TextButton.icon(
onPressed: () => setState(() {
_subjectController.clear();
_selectedOfficeId = null;
_selectedStatus = null;
_selectedDateRange = null;
}),
icon: const Icon(Icons.close),
label: const Text('Clear'),
),
],
);
final listBody = TasQAdaptiveList<Ticket>(
items: filteredTickets,
onRowTap: (ticket) => context.go('/tickets/${ticket.id}'),
summaryDashboard: summaryDashboard,
filterHeader: filterHeader,
skeletonMode: effectiveShowSkeleton,
onRequestRefresh: () {
// For server-side pagination, update the query provider
// This will trigger a reload with new pagination parameters
ref.read(ticketsQueryProvider.notifier).state =
const TicketQuery(offset: 0, limit: 50);
},
onPageChanged: (firstRow) {
ref
.read(ticketsQueryProvider.notifier)
.update((q) => q.copyWith(offset: firstRow));
},
isLoading: false,
columns: [
TasQColumn<Ticket>(
header: 'Ticket ID',
technical: true,
cellBuilder: (context, ticket) => Text(ticket.id),
),
TasQColumn<Ticket>(
header: 'Subject',
cellBuilder: (context, ticket) => Text(ticket.subject),
),
TasQColumn<Ticket>(
header: 'Office',
cellBuilder: (context, ticket) => Text(
officeById[ticket.officeId]?.name ?? ticket.officeId,
),
),
TasQColumn<Ticket>(
header: 'Filed by',
cellBuilder: (context, ticket) =>
Text(_assignedAgent(profileById, ticket.creatorId)),
),
TasQColumn<Ticket>(
header: 'Status',
cellBuilder: (context, ticket) =>
_StatusBadge(status: ticket.status),
),
TasQColumn<Ticket>(
header: 'Timestamp',
technical: true,
cellBuilder: (context, ticket) =>
Text(_formatTimestamp(ticket.createdAt)),
),
],
mobileTileBuilder: (context, ticket, actions) {
final officeName =
officeById[ticket.officeId]?.name ?? ticket.officeId;
final assigned = _assignedAgent(
profileById,
ticket.creatorId,
);
final hasMention = unreadByTicketId[ticket.id] == true;
final typingState = ref.watch(
typingIndicatorProvider(ticket.id),
);
final showTyping = typingState.userIds.isNotEmpty;
return Card(
child: ListTile(
leading: const Icon(Icons.confirmation_number_outlined),
dense: true,
visualDensity: VisualDensity.compact,
title: Text(ticket.subject),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(officeName),
const SizedBox(height: 2),
Text('Filed by: $assigned'),
const SizedBox(height: 4),
MonoText('ID ${ticket.id}'),
const SizedBox(height: 2),
Text(_formatTimestamp(ticket.createdAt)),
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
_StatusBadge(status: ticket.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('/tickets/${ticket.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(
'Tickets',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleLarge
?.copyWith(fontWeight: FontWeight.w700),
),
),
),
Expanded(child: listBody),
],
);
},
loading: () => const SizedBox.shrink(),
error: (error, _) =>
Center(child: Text('Failed to load tickets: $error')),
),
),
),
Positioned(
@ -315,6 +341,7 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
),
),
),
const ReconnectOverlay(),
],
);
}

View File

@ -0,0 +1,131 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/realtime_controller.dart';
class ReconnectOverlay extends ConsumerWidget {
const ReconnectOverlay({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final ctrl = ref.watch(realtimeControllerProvider);
if (!ctrl.isConnecting && !ctrl.isFailed) return const SizedBox.shrink();
if (ctrl.isFailed) {
return Positioned.fill(
child: AbsorbPointer(
absorbing: true,
child: Center(
child: SizedBox(
width: 420,
child: Card(
elevation: 6,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Realtime connection failed',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
ctrl.lastError ??
'Unable to reconnect after multiple attempts.',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => ctrl.retry(),
child: const Text('Retry'),
),
],
),
],
),
),
),
),
),
),
);
}
// isConnecting: show richer skeleton-like placeholders
return Positioned.fill(
child: AbsorbPointer(
absorbing: true,
child: Container(
color: Theme.of(
context,
).colorScheme.surface.withAlpha((0.35 * 255).round()),
child: Center(
child: SizedBox(
width: 640,
child: Card(
elevation: 4,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Header
Container(
height: 20,
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(6),
),
),
const SizedBox(height: 12),
// chips row
Row(
children: [
for (var i = 0; i < 3; i++)
Padding(
padding: const EdgeInsets.only(right: 8.0),
child: Container(
width: 100,
height: 14,
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(6),
),
),
),
],
),
const SizedBox(height: 16),
// lines representing content
for (var i = 0; i < 4; i++) ...[
Container(
height: 12,
margin: const EdgeInsets.only(bottom: 8),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(6),
),
),
],
],
),
),
),
),
),
),
),
);
}
}

View File

@ -1,6 +1,8 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
// skeleton rendering is controlled by the caller's `Skeletonizer` wrapper
// so this widget doesn't import `skeletonizer` directly.
import '../theme/app_typography.dart';
import '../theme/app_surfaces.dart';
@ -59,6 +61,7 @@ class TasQAdaptiveList<T> extends StatelessWidget {
this.onRequestRefresh,
this.onPageChanged,
this.isLoading = false,
this.skeletonMode = false,
});
/// The list of items to display.
@ -117,6 +120,13 @@ class TasQAdaptiveList<T> extends StatelessWidget {
/// If true, shows a loading indicator for server-side pagination.
final bool isLoading;
/// When true the widget renders skeleton placeholders for the
/// dashboard, filter panel and list items instead of the real content.
/// This is intended to provide a single-source skeleton UI for screens
/// that wrap the whole body in a `Skeletonizer` and want consistent
/// sectioned placeholders.
final bool skeletonMode;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
@ -135,6 +145,54 @@ class TasQAdaptiveList<T> extends StatelessWidget {
Widget _buildMobile(BuildContext context, BoxConstraints constraints) {
final hasBoundedHeight = constraints.hasBoundedHeight;
if (skeletonMode) {
// Render structured skeleton sections: summary, filters, and list.
final summary = summaryDashboard == null
? const SizedBox.shrink()
: Column(
children: [
SizedBox(width: double.infinity, child: summaryDashboard!),
const SizedBox(height: 12),
],
);
final filter = filterHeader == null
? const SizedBox.shrink()
: Column(
children: [
ExpansionTile(
title: const Text('Filters'),
children: [
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: filterHeader!,
),
],
),
const SizedBox(height: 8),
],
);
final skeletonList = ListView.separated(
padding: const EdgeInsets.only(bottom: 24),
itemCount: 6,
separatorBuilder: (context, index) => const SizedBox(height: 12),
itemBuilder: (context, index) => _loadingTile(context),
);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (summaryDashboard != null) ...[summary],
if (filterHeader != null) ...[filter],
Expanded(child: _buildInfiniteScrollListener(skeletonList)),
],
),
);
}
// Mobile: Single-column with infinite scroll listeners
final listView = ListView.separated(
padding: const EdgeInsets.only(bottom: 24),
@ -142,14 +200,8 @@ class TasQAdaptiveList<T> extends StatelessWidget {
separatorBuilder: (context, index) => const SizedBox(height: 12),
itemBuilder: (context, index) {
if (index >= items.length) {
// Loading indicator for infinite scroll
return Padding(
padding: const EdgeInsets.only(top: 8),
child: SizedBox(
height: 24,
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
),
);
// Loading skeleton for infinite scroll (non-blocking shimmer)
return _loadingTile(context);
}
final item = items[index];
final actions = rowActions?.call(item) ?? const <Widget>[];
@ -169,13 +221,7 @@ class TasQAdaptiveList<T> extends StatelessWidget {
separatorBuilder: (context, index) => const SizedBox(height: 12),
itemBuilder: (context, index) {
if (index >= items.length) {
return Padding(
padding: const EdgeInsets.only(top: 8),
child: SizedBox(
height: 24,
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
),
);
return _loadingTile(context);
}
final item = items[index];
final actions = rowActions?.call(item) ?? const <Widget>[];
@ -245,6 +291,44 @@ class TasQAdaptiveList<T> extends StatelessWidget {
);
}
Widget _loadingTile(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(top: 8),
child: SizedBox(
height: 72,
child: Card(
margin: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(6),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(height: 12, color: Colors.white),
const SizedBox(height: 8),
Container(height: 10, width: 150, color: Colors.white),
],
),
),
],
),
),
),
),
);
}
Widget _buildDesktop(BuildContext context, BoxConstraints constraints) {
final dataSource = _TasQTableSource<T>(
context: context,

View File

@ -1301,6 +1301,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.4.1"
skeletonizer:
dependency: "direct main"
description:
name: skeletonizer
sha256: "9f38f9b47ec3cf2235a6a4f154a88a95432bc55ba98b3e2eb6ced5c1974bc122"
url: "https://pub.dev"
source: hosted
version: "2.1.3"
sky_engine:
dependency: transitive
description: flutter

View File

@ -34,6 +34,7 @@ dependencies:
firebase_messaging: ^16.1.1
shared_preferences: ^2.2.0
uuid: ^4.1.0
skeletonizer: ^2.1.3
dev_dependencies:
flutter_test: