Skeleton loading
This commit is contained in:
parent
c5e859ad88
commit
d3239d8c76
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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(),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
131
lib/widgets/reconnect_overlay.dart
Normal file
131
lib/widgets/reconnect_overlay.dart
Normal 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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user