import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:tasq/utils/app_time.dart'; import 'package:go_router/go_router.dart'; import '../../models/office.dart'; import '../../models/notification_item.dart'; import '../../models/profile.dart'; 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'; import '../../theme/app_surfaces.dart'; import '../../utils/snackbar.dart'; class TicketsListScreen extends ConsumerStatefulWidget { const TicketsListScreen({super.key}); @override ConsumerState createState() => _TicketsListScreenState(); } class _TicketsListScreenState extends ConsumerState { final TextEditingController _subjectController = TextEditingController(); String? _selectedOfficeId; String? _selectedStatus; DateTimeRange? _selectedDateRange; bool _isInitial = true; // (previous deferred listen removed; providers are watched directly) @override void dispose() { _subjectController.dispose(); super.dispose(); } @override void initState() { super.initState(); } bool get _hasTicketFilters { return _subjectController.text.trim().isNotEmpty || _selectedOfficeId != null || _selectedStatus != null || _selectedDateRange != null; } @override Widget build(BuildContext context) { final realtime = ref.watch(realtimeControllerProvider); final ticketsAsync = ref.watch(ticketsProvider); final officesAsync = ref.watch(officesProvider); final notificationsAsync = ref.watch(notificationsProvider); final profilesAsync = ref.watch(profilesProvider); 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: Skeletonizer( enabled: effectiveShowSkeleton, child: Builder( builder: (context) { // Build the list UI immediately so `Skeletonizer` can // render placeholders while providers are still loading. final tickets = ticketsAsync.valueOrNull ?? []; final officeById = { for (final office in officesAsync.valueOrNull ?? []) office.id: office, }; final profileById = { for (final profile in profilesAsync.valueOrNull ?? []) profile.id: profile, }; final unreadByTicketId = _unreadByTicketId(notificationsAsync); final offices = officesAsync.valueOrNull ?? []; final officesSorted = List.from(offices) ..sort( (a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()), ); final officeOptions = >[ const DropdownMenuItem( value: null, child: Text('All offices'), ), ...officesSorted.map( (office) => DropdownMenuItem( 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( isExpanded: true, key: ValueKey(_selectedOfficeId), initialValue: _selectedOfficeId, items: officeOptions, onChanged: (value) => setState(() => _selectedOfficeId = value), decoration: const InputDecoration(labelText: 'Office'), ), ), SizedBox( width: 180, child: DropdownButtonFormField( 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( items: filteredTickets, onRowTap: (ticket) => context.go('/tickets/${ticket.id}'), summaryDashboard: summaryDashboard, filterHeader: filterHeader, skeletonMode: effectiveShowSkeleton, onRequestRefresh: () { ref.read(ticketsQueryProvider.notifier).state = const TicketQuery(offset: 0, limit: 50); }, onPageChanged: (firstRow) { ref .read(ticketsQueryProvider.notifier) .update((q) => q.copyWith(offset: firstRow)); }, isLoading: ticketsAsync.maybeWhen( loading: () => true, orElse: () => false, ), columns: [ TasQColumn( header: 'Ticket ID', technical: true, cellBuilder: (context, ticket) => Text(ticket.id), ), TasQColumn( header: 'Subject', cellBuilder: (context, ticket) => Text(ticket.subject), ), TasQColumn( header: 'Office', cellBuilder: (context, ticket) => Text( officeById[ticket.officeId]?.name ?? ticket.officeId, ), ), TasQColumn( header: 'Filed by', cellBuilder: (context, ticket) => Text(_assignedAgent(profileById, ticket.creatorId)), ), TasQColumn( header: 'Status', cellBuilder: (context, ticket) => _StatusBadge(status: ticket.status), ), TasQColumn( 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), ], ); }, ), ), ), Positioned( right: 16, bottom: 16, child: SafeArea( child: FloatingActionButton.extended( onPressed: () => _showCreateTicketDialog(context, ref), icon: const Icon(Icons.add), label: const Text('New Ticket'), ), ), ), const ReconnectOverlay(), ], ); } Future _showCreateTicketDialog( BuildContext context, WidgetRef ref, ) async { final subjectController = TextEditingController(); final descriptionController = TextEditingController(); Office? selectedOffice; await showDialog( context: context, builder: (dialogContext) { bool saving = false; return StatefulBuilder( builder: (context, setState) { return AlertDialog( shape: AppSurfaces.of(context).dialogShape, title: const Text('Create Ticket'), content: Consumer( builder: (context, ref, child) { final officesAsync = ref.watch(officesProvider); return SizedBox( width: 360, child: Column( mainAxisSize: MainAxisSize.min, children: [ TextField( controller: subjectController, decoration: const InputDecoration( labelText: 'Subject', ), enabled: !saving, ), const SizedBox(height: 12), TextField( controller: descriptionController, decoration: const InputDecoration( labelText: 'Description', ), maxLines: 3, enabled: !saving, ), const SizedBox(height: 12), officesAsync.when( data: (offices) { if (offices.isEmpty) { return const Text('No offices assigned.'); } final officesSorted = List.from(offices) ..sort( (a, b) => a.name.toLowerCase().compareTo( b.name.toLowerCase(), ), ); selectedOffice ??= officesSorted.first; return DropdownButtonFormField( key: ValueKey(selectedOffice?.id), initialValue: selectedOffice, items: officesSorted .map( (office) => DropdownMenuItem( value: office, child: Text(office.name), ), ) .toList(), onChanged: saving ? null : (value) => setState(() => selectedOffice = value), decoration: const InputDecoration( labelText: 'Office', ), ); }, loading: () => const LinearProgressIndicator(), error: (error, _) => Text('Failed to load offices: $error'), ), ], ), ); }, ), actions: [ TextButton( onPressed: saving ? null : () => Navigator.of(dialogContext).pop(), child: const Text('Cancel'), ), FilledButton( onPressed: saving ? null : () async { final subject = subjectController.text.trim(); final description = descriptionController.text.trim(); if (subject.isEmpty || description.isEmpty || selectedOffice == null) { showWarningSnackBar( context, 'Fill out all fields.', ); return; } setState(() => saving = true); await ref .read(ticketsControllerProvider) .createTicket( subject: subject, description: description, officeId: selectedOffice!.id, ); // Supabase stream will emit the new ticket — no explicit // invalidation required and avoids a temporary reload. if (context.mounted) { Navigator.of(dialogContext).pop(); showSuccessSnackBar( context, 'Ticket "$subject" has been created successfully.', ); } }, child: saving ? const SizedBox( height: 18, width: 18, child: CircularProgressIndicator(strokeWidth: 2), ) : const Text('Create'), ), ], ); }, ); }, ); } Map _unreadByTicketId( AsyncValue> notificationsAsync, ) { return notificationsAsync.maybeWhen( data: (items) { final map = {}; for (final item in items) { if (item.ticketId == null) continue; if (item.isUnread) { map[item.ticketId!] = true; } } return map; }, orElse: () => {}, ); } } List> _ticketStatusOptions(List tickets) { final statuses = tickets.map((ticket) => ticket.status).toSet().toList() ..sort(); return [ const DropdownMenuItem(value: null, child: Text('All statuses')), ...statuses.map( (status) => DropdownMenuItem(value: status, child: Text(status)), ), ]; } List _applyTicketFilters( List tickets, { required String subjectQuery, required String? officeId, required String? status, required DateTimeRange? dateRange, }) { final query = subjectQuery.trim().toLowerCase(); return tickets.where((ticket) { if (query.isNotEmpty && !ticket.subject.toLowerCase().contains(query) && !ticket.id.toLowerCase().contains(query)) { return false; } if (officeId != null && ticket.officeId != officeId) { return false; } if (status != null && ticket.status != status) { return false; } if (dateRange != null) { final start = DateTime( dateRange.start.year, dateRange.start.month, dateRange.start.day, ); final end = DateTime( dateRange.end.year, dateRange.end.month, dateRange.end.day, 23, 59, 59, ); if (ticket.createdAt.isBefore(start) || ticket.createdAt.isAfter(end)) { return false; } } return true; }).toList(); } Map _statusCounts(List tickets) { final counts = {}; for (final ticket in tickets) { counts.update(ticket.status, (value) => value + 1, ifAbsent: () => 1); } return counts; } class _StatusSummaryRow extends StatelessWidget { const _StatusSummaryRow({required this.counts}); final Map counts; @override Widget build(BuildContext context) { if (counts.isEmpty) { return const SizedBox.shrink(); } final entries = counts.entries.toList() ..sort((a, b) => a.key.compareTo(b.key)); return LayoutBuilder( builder: (context, constraints) { final maxWidth = constraints.maxWidth; final maxPerRow = maxWidth >= 1000 ? 4 : maxWidth >= 720 ? 3 : maxWidth >= 480 ? 2 : entries.length; final perRow = entries.length < maxPerRow ? entries.length : maxPerRow; final spacing = maxWidth < 480 ? 8.0 : 12.0; final itemWidth = perRow == 0 ? maxWidth : (maxWidth - spacing * (perRow - 1)) / perRow; return Wrap( spacing: spacing, runSpacing: spacing, children: [ for (final entry in entries) SizedBox( width: itemWidth, child: _StatusSummaryCard( status: entry.key, count: entry.value, ), ), ], ); }, ); } } class _StatusSummaryCard extends StatelessWidget { const _StatusSummaryCard({required this.status, required this.count}); final String status; final int count; @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; final background = switch (status) { 'critical' => scheme.errorContainer, 'pending' => scheme.tertiaryContainer, 'promoted' => scheme.secondaryContainer, 'closed' => scheme.primaryContainer, _ => scheme.surfaceContainerHigh, }; final foreground = switch (status) { 'critical' => scheme.onErrorContainer, 'pending' => scheme.onTertiaryContainer, 'promoted' => scheme.onSecondaryContainer, 'closed' => scheme.onPrimaryContainer, _ => scheme.onSurfaceVariant, }; return Card( color: background, // summary cards are compact — use compact token for consistent density shape: AppSurfaces.of(context).compactShape, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( status.toUpperCase(), style: Theme.of(context).textTheme.labelSmall?.copyWith( color: foreground, fontWeight: FontWeight.w600, letterSpacing: 0.4, ), ), const SizedBox(height: 6), Text( count.toString(), style: Theme.of(context).textTheme.headlineSmall?.copyWith( color: foreground, fontWeight: FontWeight.w700, ), ), ], ), ), ); } } String _assignedAgent(Map profileById, String? userId) { if (userId == null || userId.isEmpty) { return 'Unassigned'; } final profile = profileById[userId]; if (profile == null) { return userId; } return profile.fullName.isNotEmpty ? profile.fullName : profile.id; } String _formatTimestamp(DateTime value) { final year = value.year.toString().padLeft(4, '0'); final month = value.month.toString().padLeft(2, '0'); final day = value.day.toString().padLeft(2, '0'); final hour = value.hour.toString().padLeft(2, '0'); final minute = value.minute.toString().padLeft(2, '0'); return '$year-$month-$day $hour:$minute'; } class _StatusBadge extends StatelessWidget { const _StatusBadge({required this.status}); final String status; @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; final background = switch (status) { 'critical' => scheme.errorContainer, 'pending' => scheme.tertiaryContainer, 'promoted' => scheme.secondaryContainer, 'closed' => scheme.primaryContainer, _ => scheme.surfaceContainerHighest, }; final foreground = switch (status) { 'critical' => scheme.onErrorContainer, 'pending' => scheme.onTertiaryContainer, 'promoted' => scheme.onSecondaryContainer, 'closed' => scheme.onPrimaryContainer, _ => scheme.onSurfaceVariant, }; return Badge( backgroundColor: background, label: Text( status.toUpperCase(), style: Theme.of(context).textTheme.labelSmall?.copyWith( color: foreground, fontWeight: FontWeight.w600, letterSpacing: 0.3, ), ), ); } }