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

View File

@ -10,6 +10,8 @@ import '../../providers/profile_provider.dart';
import '../../providers/tasks_provider.dart'; import '../../providers/tasks_provider.dart';
import '../../providers/tickets_provider.dart'; import '../../providers/tickets_provider.dart';
import '../../widgets/responsive_body.dart'; import '../../widgets/responsive_body.dart';
import '../../providers/realtime_controller.dart';
import 'package:skeletonizer/skeletonizer.dart';
import '../../theme/app_surfaces.dart'; import '../../theme/app_surfaces.dart';
import '../../widgets/mono_text.dart'; import '../../widgets/mono_text.dart';
import '../../widgets/status_pill.dart'; import '../../widgets/status_pill.dart';
@ -294,97 +296,150 @@ class DashboardScreen extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ResponsiveBody( final realtime = ProviderScope.containerOf(
child: LayoutBuilder( context,
builder: (context, constraints) { ).read(realtimeControllerProvider);
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( return ResponsiveBody(
mainAxisSize: MainAxisSize.min, child: Skeletonizer(
crossAxisAlignment: CrossAxisAlignment.start, enabled: realtime.isConnecting,
children: [ child: LayoutBuilder(
Padding( builder: (context, constraints) {
padding: const EdgeInsets.only(top: 16, bottom: 8), final sections = <Widget>[
child: Align( const SizedBox(height: 16),
alignment: Alignment.center, _sectionTitle(context, 'IT Staff Pulse'),
child: Text( const _StaffTable(),
'Dashboard', const SizedBox(height: 20),
textAlign: TextAlign.center, _sectionTitle(context, 'Core Daily KPIs'),
style: Theme.of(context).textTheme.titleLarge?.copyWith( _cardGrid(context, [
fontWeight: FontWeight.w700, _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(),
const _DashboardStatusBanner(), ...sections,
...sections, ],
], );
);
return SingleChildScrollView( return Stack(
padding: const EdgeInsets.only(bottom: 24), children: [
child: Center( SingleChildScrollView(
child: ConstrainedBox( padding: const EdgeInsets.only(bottom: 24),
constraints: BoxConstraints(minHeight: constraints.maxHeight), child: Center(
child: content, 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/profile_provider.dart';
import '../../providers/tasks_provider.dart'; import '../../providers/tasks_provider.dart';
import '../../providers/tickets_provider.dart'; import '../../providers/tickets_provider.dart';
import '../../providers/realtime_controller.dart';
import '../../providers/typing_provider.dart'; import '../../providers/typing_provider.dart';
import '../../widgets/mono_text.dart'; import '../../widgets/mono_text.dart';
import '../../widgets/reconnect_overlay.dart';
import 'package:skeletonizer/skeletonizer.dart';
import '../../widgets/responsive_body.dart'; import '../../widgets/responsive_body.dart';
import '../../widgets/tasq_adaptive_list.dart'; import '../../widgets/tasq_adaptive_list.dart';
import '../../widgets/typing_dots.dart'; import '../../widgets/typing_dots.dart';
@ -52,6 +55,7 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
String? _selectedAssigneeId; String? _selectedAssigneeId;
DateTimeRange? _selectedDateRange; DateTimeRange? _selectedDateRange;
late final TabController _tabController; late final TabController _tabController;
bool _isSwitchingTab = false;
@override @override
void dispose() { void dispose() {
@ -66,8 +70,15 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
super.initState(); super.initState();
_tabController = TabController(length: 2, vsync: this); _tabController = TabController(length: 2, vsync: this);
_tabController.addListener(() { _tabController.addListener(() {
// rebuild when tab changes so filters shown/hidden update // briefly show a skeleton when switching tabs so the UI can
setState(() {}); // 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 notificationsAsync = ref.watch(notificationsProvider);
final profilesAsync = ref.watch(profilesProvider); final profilesAsync = ref.watch(profilesProvider);
final assignmentsAsync = ref.watch(taskAssignmentsProvider); 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( final canCreate = profileAsync.maybeWhen(
data: (profile) => data: (profile) =>
@ -117,289 +139,226 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
children: [ children: [
ResponsiveBody( ResponsiveBody(
maxWidth: double.infinity, maxWidth: double.infinity,
child: tasksAsync.when( child: Skeletonizer(
data: (tasks) { enabled: effectiveShowSkeleton,
if (tasks.isEmpty) { child: tasksAsync.when(
return const Center(child: Text('No tasks yet.')); data: (tasks) {
} if (tasks.isEmpty) {
final offices = officesAsync.valueOrNull ?? <Office>[]; return const Center(child: Text('No tasks yet.'));
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 offices = officesAsync.valueOrNull ?? <Office>[];
final latestAssigneeByTaskId = <String, String?>{}; final officesSorted = List<Office>.from(offices)
for (final entry in assignmentsByTask.entries) { ..sort(
latestAssigneeByTaskId[entry.key] = entry.value.userId; (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( // derive latest assignee per task from task assignments stream
tasks, final assignments =
ticketById: ticketById, assignmentsAsync.valueOrNull ?? <TaskAssignment>[];
subjectQuery: _subjectController.text, final assignmentsByTask = <String, TaskAssignment>{};
taskNumber: _taskNumberController.text, for (final a in assignments) {
officeId: _selectedOfficeId, final current = assignmentsByTask[a.taskId];
status: _selectedStatus, if (current == null ||
assigneeId: _selectedAssigneeId, a.createdAt.isAfter(current.createdAt)) {
dateRange: _selectedDateRange, assignmentsByTask[a.taskId] = a;
latestAssigneeByTaskId: latestAssigneeByTaskId, }
); }
final latestAssigneeByTaskId = <String, String?>{};
for (final entry in assignmentsByTask.entries) {
latestAssigneeByTaskId[entry.key] = entry.value.userId;
}
final filterHeader = Wrap( final filteredTasks = _applyTaskFilters(
spacing: 12, tasks,
runSpacing: 12, ticketById: ticketById,
crossAxisAlignment: WrapCrossAlignment.center, subjectQuery: _subjectController.text,
children: [ taskNumber: _taskNumberController.text,
SizedBox( officeId: _selectedOfficeId,
width: 220, status: _selectedStatus,
child: TextField( assigneeId: _selectedAssigneeId,
controller: _subjectController, dateRange: _selectedDateRange,
onChanged: (_) => setState(() {}), latestAssigneeByTaskId: latestAssigneeByTaskId,
decoration: const InputDecoration( );
labelText: 'Subject',
prefixIcon: Icon(Icons.search), final filterHeader = Wrap(
), spacing: 12,
), runSpacing: 12,
), crossAxisAlignment: WrapCrossAlignment.center,
SizedBox( children: [
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( SizedBox(
width: 220, width: 220,
child: TextField(
controller: _subjectController,
onChanged: (_) => setState(() {}),
decoration: const InputDecoration(
labelText: 'Subject',
prefixIcon: Icon(Icons.search),
),
),
),
SizedBox(
width: 200,
child: DropdownButtonFormField<String?>( child: DropdownButtonFormField<String?>(
isExpanded: true, isExpanded: true,
key: ValueKey(_selectedAssigneeId), key: ValueKey(_selectedOfficeId),
initialValue: _selectedAssigneeId, initialValue: _selectedOfficeId,
items: staffOptions, items: officeOptions,
onChanged: (value) => 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( decoration: const InputDecoration(
labelText: 'Assigned staff', labelText: 'Task #',
prefixIcon: Icon(Icons.filter_alt),
), ),
), ),
), ),
SizedBox( if (_tabController.index == 1)
width: 180, SizedBox(
child: DropdownButtonFormField<String?>( width: 220,
isExpanded: true, child: DropdownButtonFormField<String?>(
key: ValueKey(_selectedStatus), isExpanded: true,
initialValue: _selectedStatus, key: ValueKey(_selectedAssigneeId),
items: statusOptions, initialValue: _selectedAssigneeId,
onChanged: (value) => items: staffOptions,
setState(() => _selectedStatus = value), onChanged: (value) =>
decoration: const InputDecoration(labelText: 'Status'), setState(() => _selectedAssigneeId = value),
), decoration: const InputDecoration(
), labelText: 'Assigned staff',
OutlinedButton.icon( ),
onPressed: () async { ),
final next = await showDateRangePicker( ),
context: context, SizedBox(
firstDate: DateTime(2020), width: 180,
lastDate: AppTime.now().add(const Duration(days: 365)), child: DropdownButtonFormField<String?>(
currentDate: AppTime.now(), isExpanded: true,
initialDateRange: _selectedDateRange, key: ValueKey(_selectedStatus),
); initialValue: _selectedStatus,
if (!mounted) return; items: statusOptions,
setState(() => _selectedDateRange = next); onChanged: (value) =>
}, setState(() => _selectedStatus = value),
icon: const Icon(Icons.date_range), decoration: const InputDecoration(labelText: 'Status'),
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,
),
],
],
), ),
), ),
TasQColumn<Task>( OutlinedButton.icon(
header: 'Timestamp', onPressed: () async {
technical: true, final next = await showDateRangePicker(
cellBuilder: (context, task) => context: context,
Text(_formatTimestamp(task.createdAt)), 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( // reusable helper for rendering a list given a subset of tasks
child: ListTile( Widget makeList(List<Task> tasksList) {
leading: _buildQueueBadge(context, task), final summary = _StatusSummaryRow(
dense: true, counts: _taskStatusCounts(tasksList),
visualDensity: VisualDensity.compact, );
title: Text( return TasQAdaptiveList<Task>(
task.title.isNotEmpty items: tasksList,
? task.title onRowTap: (task) => context.go('/tasks/${task.id}'),
: (ticket?.subject ?? summaryDashboard: summary,
'Task ${task.taskNumber ?? task.id}'), filterHeader: filterHeader,
), skeletonMode: effectiveShowSkeleton,
subtitle: Column( onRequestRefresh: () {
crossAxisAlignment: CrossAxisAlignment.start, // For server-side pagination, update the query provider
children: [ ref.read(tasksQueryProvider.notifier).state =
Text(subtitle), const TaskQuery(offset: 0, limit: 50);
const SizedBox(height: 2), },
Text('Assigned: $assigned'), onPageChanged: null,
const SizedBox(height: 4), isLoading: false,
MonoText('ID ${task.taskNumber ?? task.id}'), columns: [
const SizedBox(height: 2), TasQColumn<Task>(
Text(_formatTimestamp(task.createdAt)), header: 'Task #',
], technical: true,
), cellBuilder: (context, task) =>
trailing: Row( 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, mainAxisSize: MainAxisSize.min,
children: [ children: [
_StatusBadge(status: task.status), _StatusBadge(status: task.status),
@ -412,85 +371,155 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
color: Colors.orange, 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}'),
), ),
); TasQColumn<Task>(
}, header: 'Timestamp',
); technical: true,
} cellBuilder: (context, task) =>
Text(_formatTimestamp(task.createdAt)),
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,
),
), ),
), ],
), mobileTileBuilder: (context, task, actions) {
Expanded( final ticketId = task.ticketId;
child: Column( final ticket = ticketId == null
children: [ ? null
TabBar( : ticketById[ticketId];
controller: _tabController, final officeId = ticket?.officeId ?? task.officeId;
tabs: const [ final officeName = officeId == null
Tab(text: 'My Tasks'), ? 'Unassigned office'
Tab(text: 'All Tasks'), : (officeById[officeId]?.name ?? officeId);
], final assigned = _assignedAgent(
), profileById,
Expanded( latestAssigneeByTaskId[task.id],
child: TabBarView( );
controller: _tabController, 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: [ children: [
makeList(myTasks), Text(subtitle),
makeList(filteredTasks), 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),
),
),
), ),
), Expanded(
], child: Column(
); children: [
}, TabBar(
loading: () => const Center(child: CircularProgressIndicator()), controller: _tabController,
error: (error, _) => tabs: const [
Center(child: Text('Failed to load tasks: $error')), 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) 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/notifications_provider.dart';
import '../../providers/profile_provider.dart'; import '../../providers/profile_provider.dart';
import '../../providers/tickets_provider.dart'; import '../../providers/tickets_provider.dart';
import '../../providers/realtime_controller.dart';
import '../../providers/typing_provider.dart'; import '../../providers/typing_provider.dart';
import '../../widgets/mono_text.dart'; import '../../widgets/mono_text.dart';
import '../../widgets/reconnect_overlay.dart';
import 'package:skeletonizer/skeletonizer.dart';
import '../../widgets/responsive_body.dart'; import '../../widgets/responsive_body.dart';
import '../../widgets/tasq_adaptive_list.dart'; import '../../widgets/tasq_adaptive_list.dart';
import '../../widgets/typing_dots.dart'; import '../../widgets/typing_dots.dart';
@ -30,6 +33,7 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
String? _selectedOfficeId; String? _selectedOfficeId;
String? _selectedStatus; String? _selectedStatus;
DateTimeRange? _selectedDateRange; DateTimeRange? _selectedDateRange;
bool _isInitial = true;
@override @override
void dispose() { void dispose() {
@ -50,258 +54,280 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
final officesAsync = ref.watch(officesProvider); final officesAsync = ref.watch(officesProvider);
final notificationsAsync = ref.watch(notificationsProvider); final notificationsAsync = ref.watch(notificationsProvider);
final profilesAsync = ref.watch(profilesProvider); 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( return Stack(
children: [ children: [
ResponsiveBody( ResponsiveBody(
maxWidth: double.infinity, maxWidth: double.infinity,
child: ticketsAsync.when( child: Skeletonizer(
data: (tickets) { enabled: effectiveShowSkeleton,
if (tickets.isEmpty) { child: ticketsAsync.when(
return const Center(child: Text('No tickets yet.')); data: (tickets) {
} if (tickets.isEmpty) {
final officeById = <String, Office>{ return const Center(child: Text('No tickets yet.'));
for (final office in officesAsync.valueOrNull ?? <Office>[]) }
office.id: office, final officeById = <String, Office>{
}; for (final office in officesAsync.valueOrNull ?? <Office>[])
final profileById = <String, Profile>{ office.id: office,
for (final profile in profilesAsync.valueOrNull ?? <Profile>[]) };
profile.id: profile, final profileById = <String, Profile>{
}; for (final profile
final unreadByTicketId = _unreadByTicketId(notificationsAsync); in profilesAsync.valueOrNull ?? <Profile>[])
final offices = officesAsync.valueOrNull ?? <Office>[]; profile.id: profile,
final officesSorted = List<Office>.from(offices) };
..sort( final unreadByTicketId = _unreadByTicketId(notificationsAsync);
(a, b) => final offices = officesAsync.valueOrNull ?? <Office>[];
a.name.toLowerCase().compareTo(b.name.toLowerCase()), 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?>>[ final summaryDashboard = _StatusSummaryRow(
const DropdownMenuItem<String?>( counts: _statusCounts(filteredTickets),
value: null, );
child: Text('All offices'), final filterHeader = Wrap(
), spacing: 12,
...officesSorted.map( runSpacing: 12,
(office) => DropdownMenuItem<String?>( crossAxisAlignment: WrapCrossAlignment.center,
value: office.id, children: [
child: Text(office.name), SizedBox(
), width: 220,
), child: TextField(
]; controller: _subjectController,
final statusOptions = _ticketStatusOptions(tickets); onChanged: (_) => setState(() {}),
final filteredTickets = _applyTicketFilters( decoration: const InputDecoration(
tickets, labelText: 'Subject',
subjectQuery: _subjectController.text, prefixIcon: Icon(Icons.search),
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,
), ),
), ),
), ),
), SizedBox(
Expanded(child: listBody), width: 200,
], child: DropdownButtonFormField<String?>(
); isExpanded: true,
}, key: ValueKey(_selectedOfficeId),
loading: () => const Center(child: CircularProgressIndicator()), initialValue: _selectedOfficeId,
error: (error, _) => items: officeOptions,
Center(child: Text('Failed to load tickets: $error')), 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( 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 'dart:math' as math;
import 'package:flutter/material.dart'; 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_typography.dart';
import '../theme/app_surfaces.dart'; import '../theme/app_surfaces.dart';
@ -59,6 +61,7 @@ class TasQAdaptiveList<T> extends StatelessWidget {
this.onRequestRefresh, this.onRequestRefresh,
this.onPageChanged, this.onPageChanged,
this.isLoading = false, this.isLoading = false,
this.skeletonMode = false,
}); });
/// The list of items to display. /// 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. /// If true, shows a loading indicator for server-side pagination.
final bool isLoading; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return LayoutBuilder( return LayoutBuilder(
@ -135,6 +145,54 @@ class TasQAdaptiveList<T> extends StatelessWidget {
Widget _buildMobile(BuildContext context, BoxConstraints constraints) { Widget _buildMobile(BuildContext context, BoxConstraints constraints) {
final hasBoundedHeight = constraints.hasBoundedHeight; 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 // Mobile: Single-column with infinite scroll listeners
final listView = ListView.separated( final listView = ListView.separated(
padding: const EdgeInsets.only(bottom: 24), padding: const EdgeInsets.only(bottom: 24),
@ -142,14 +200,8 @@ class TasQAdaptiveList<T> extends StatelessWidget {
separatorBuilder: (context, index) => const SizedBox(height: 12), separatorBuilder: (context, index) => const SizedBox(height: 12),
itemBuilder: (context, index) { itemBuilder: (context, index) {
if (index >= items.length) { if (index >= items.length) {
// Loading indicator for infinite scroll // Loading skeleton for infinite scroll (non-blocking shimmer)
return Padding( return _loadingTile(context);
padding: const EdgeInsets.only(top: 8),
child: SizedBox(
height: 24,
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
),
);
} }
final item = items[index]; final item = items[index];
final actions = rowActions?.call(item) ?? const <Widget>[]; final actions = rowActions?.call(item) ?? const <Widget>[];
@ -169,13 +221,7 @@ class TasQAdaptiveList<T> extends StatelessWidget {
separatorBuilder: (context, index) => const SizedBox(height: 12), separatorBuilder: (context, index) => const SizedBox(height: 12),
itemBuilder: (context, index) { itemBuilder: (context, index) {
if (index >= items.length) { if (index >= items.length) {
return Padding( return _loadingTile(context);
padding: const EdgeInsets.only(top: 8),
child: SizedBox(
height: 24,
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
),
);
} }
final item = items[index]; final item = items[index];
final actions = rowActions?.call(item) ?? const <Widget>[]; 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) { Widget _buildDesktop(BuildContext context, BoxConstraints constraints) {
final dataSource = _TasQTableSource<T>( final dataSource = _TasQTableSource<T>(
context: context, context: context,

View File

@ -1301,6 +1301,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.1" 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: sky_engine:
dependency: transitive dependency: transitive
description: flutter description: flutter

View File

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