Skeleton loading

This commit is contained in:
2026-02-28 21:47:24 +08:00
parent c5e859ad88
commit d3239d8c76
9 changed files with 2755 additions and 2285 deletions
+270 -243
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(),
],
);
}