Skeleton loading
This commit is contained in:
@@ -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(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user