diff --git a/lib/models/task.dart b/lib/models/task.dart index c4e9f3ae..bc6a04a0 100644 --- a/lib/models/task.dart +++ b/lib/models/task.dart @@ -59,6 +59,59 @@ class Task { final String? cancellationReason; final DateTime? cancelledAt; + @override + bool operator ==(Object other) => + identical(this, other) || + other is Task && + runtimeType == other.runtimeType && + id == other.id && + ticketId == other.ticketId && + taskNumber == other.taskNumber && + title == other.title && + description == other.description && + officeId == other.officeId && + status == other.status && + priority == other.priority && + queueOrder == other.queueOrder && + createdAt == other.createdAt && + creatorId == other.creatorId && + startedAt == other.startedAt && + completedAt == other.completedAt && + requestedBy == other.requestedBy && + notedBy == other.notedBy && + receivedBy == other.receivedBy && + requestType == other.requestType && + requestTypeOther == other.requestTypeOther && + requestCategory == other.requestCategory && + actionTaken == other.actionTaken && + cancellationReason == other.cancellationReason && + cancelledAt == other.cancelledAt; + + @override + int get hashCode => Object.hash( + id, + ticketId, + taskNumber, + title, + description, + officeId, + status, + priority, + queueOrder, + createdAt, + creatorId, + startedAt, + completedAt, + requestedBy, + notedBy, + receivedBy, + requestType, + requestTypeOther, + requestCategory, + // Object.hash supports max 20 positional args; combine remainder. + Object.hash(actionTaken, cancellationReason, cancelledAt), + ); + /// Helper that indicates whether a completed task still has missing /// metadata such as signatories or action details. The parameter is used /// by UI to surface a warning icon/banner when a task has been closed but diff --git a/lib/models/ticket.dart b/lib/models/ticket.dart index 6bc1c094..1172d561 100644 --- a/lib/models/ticket.dart +++ b/lib/models/ticket.dart @@ -25,6 +25,36 @@ class Ticket { final DateTime? promotedAt; final DateTime? closedAt; + @override + bool operator ==(Object other) => + identical(this, other) || + other is Ticket && + runtimeType == other.runtimeType && + id == other.id && + subject == other.subject && + description == other.description && + officeId == other.officeId && + status == other.status && + createdAt == other.createdAt && + creatorId == other.creatorId && + respondedAt == other.respondedAt && + promotedAt == other.promotedAt && + closedAt == other.closedAt; + + @override + int get hashCode => Object.hash( + id, + subject, + description, + officeId, + status, + createdAt, + creatorId, + respondedAt, + promotedAt, + closedAt, + ); + factory Ticket.fromMap(Map map) { return Ticket( id: map['id'] as String, diff --git a/lib/providers/realtime_controller.dart b/lib/providers/realtime_controller.dart index 3315a721..41ef84cf 100644 --- a/lib/providers/realtime_controller.dart +++ b/lib/providers/realtime_controller.dart @@ -94,8 +94,12 @@ class RealtimeController extends ChangeNotifier { /// Convenience callback suitable for [StreamRecoveryWrapper.onStatusChanged]. /// /// Routes [StreamConnectionStatus] to the appropriate mark method. + /// Both `connected` and `polling` are treated as "recovered" because + /// polling is a functional fallback that still delivers data — the user + /// doesn't need to see a reconnection indicator while data flows via REST. void handleChannelStatus(String channel, StreamConnectionStatus status) { - if (status == StreamConnectionStatus.connected) { + if (status == StreamConnectionStatus.connected || + status == StreamConnectionStatus.polling) { markChannelRecovered(channel); } else { markChannelRecovering(channel); diff --git a/lib/providers/stream_recovery.dart b/lib/providers/stream_recovery.dart index fc8f29df..c35b0d21 100644 --- a/lib/providers/stream_recovery.dart +++ b/lib/providers/stream_recovery.dart @@ -109,6 +109,7 @@ class StreamRecoveryWrapper { int _recoveryAttempts = 0; Timer? _pollingTimer; Timer? _recoveryTimer; + Timer? _stabilityTimer; StreamSubscription>>? _realtimeSub; StreamController>? _controller; bool _disposed = false; @@ -159,8 +160,28 @@ class StreamRecoveryWrapper { void _onRealtimeData(List> rows) { if (_disposed) return; - // Successful data — reset recovery counters. - _recoveryAttempts = 0; + + // When recovering, don't reset _recoveryAttempts immediately. + // Supabase streams emit an initial REST fetch before the realtime + // channel is established. If the channel keeps failing, resetting + // on that REST data creates an infinite loop (data → reset → subscribe + // → fail → data → reset …). Instead, start a stability timer — only + // reset after staying connected without errors for 30 seconds. + if (_recoveryAttempts > 0) { + _stabilityTimer?.cancel(); + _stabilityTimer = Timer(const Duration(seconds: 30), () { + if (!_disposed) { + _recoveryAttempts = 0; + debugPrint( + 'StreamRecoveryWrapper[$channelName]: ' + 'connection stable for 30s — recovery counter reset', + ); + } + }); + } else { + _recoveryAttempts = 0; + } + _setStatus(StreamConnectionStatus.connected); _emit( StreamRecoveryResult( @@ -174,6 +195,9 @@ class StreamRecoveryWrapper { void _onRealtimeError(Object error, [StackTrace? stack]) { if (_disposed) return; + // Cancel any stability timer — the connection is not stable. + _stabilityTimer?.cancel(); + final isRateLimit = _isRateLimitError(error); final isTimeout = _isTimeoutError(error); final tag = isRateLimit @@ -337,6 +361,7 @@ class StreamRecoveryWrapper { _disposed = true; _pollingTimer?.cancel(); _recoveryTimer?.cancel(); + _stabilityTimer?.cancel(); _realtimeSub?.cancel(); // Ensure the channel is removed from the recovering set when the // wrapper is torn down (e.g. provider disposed during navigation). diff --git a/lib/providers/tasks_provider.dart b/lib/providers/tasks_provider.dart index 0f79bbc4..8b760a58 100644 --- a/lib/providers/tasks_provider.dart +++ b/lib/providers/tasks_provider.dart @@ -526,6 +526,30 @@ List> _processTasksInIsolate( /// Provider for task query parameters. final tasksQueryProvider = StateProvider((ref) => const TaskQuery()); +/// Derived provider that selects a single [Task] by ID from the tasks list. +/// +/// Because [Task] implements `==`, this provider only notifies watchers when +/// the specific task's data actually changes — not when unrelated tasks in the +/// list are updated. Use this in detail screens to avoid full-list rebuilds. +final taskByIdProvider = Provider.family((ref, taskId) { + return ref + .watch(tasksProvider) + .valueOrNull + ?.where((t) => t.id == taskId) + .firstOrNull; +}); + +/// Derived provider that selects a [Task] linked to a given ticket ID. +/// +/// Returns the first task whose `ticketId` matches [ticketId], or null. +final taskByTicketIdProvider = Provider.family((ref, ticketId) { + return ref + .watch(tasksProvider) + .valueOrNull + ?.where((t) => t.ticketId == ticketId) + .firstOrNull; +}); + final taskAssignmentsProvider = StreamProvider>((ref) { final client = ref.watch(supabaseClientProvider); diff --git a/lib/providers/tickets_provider.dart b/lib/providers/tickets_provider.dart index d3eacd83..4521f9ce 100644 --- a/lib/providers/tickets_provider.dart +++ b/lib/providers/tickets_provider.dart @@ -361,6 +361,19 @@ final ticketsQueryProvider = StateProvider( (ref) => const TicketQuery(), ); +/// Derived provider that selects a single [Ticket] by ID from the tickets list. +/// +/// Because [Ticket] implements `==`, this provider only notifies watchers when +/// the specific ticket's data actually changes — not when unrelated tickets in +/// the list are updated. Use this in detail screens to avoid full-list rebuilds. +final ticketByIdProvider = Provider.family((ref, ticketId) { + return ref + .watch(ticketsProvider) + .valueOrNull + ?.where((t) => t.id == ticketId) + .firstOrNull; +}); + final ticketMessagesProvider = StreamProvider.family, String>((ref, ticketId) { final client = ref.watch(supabaseClientProvider); diff --git a/lib/screens/tasks/task_detail_screen.dart b/lib/screens/tasks/task_detail_screen.dart index 8c0872ee..7ada4220 100644 --- a/lib/screens/tasks/task_detail_screen.dart +++ b/lib/screens/tasks/task_detail_screen.dart @@ -192,15 +192,24 @@ class _TaskDetailScreenState extends ConsumerState @override Widget build(BuildContext context) { - final tasksAsync = ref.watch(tasksProvider); - final ticketsAsync = ref.watch(ticketsProvider); + // Use per-item providers to avoid rebuilding when unrelated tasks/tickets + // change. Only the specific task/ticket for this screen triggers rebuilds. + final task = ref.watch(taskByIdProvider(widget.taskId)); final officesAsync = ref.watch(officesProvider); final profileAsync = ref.watch(currentProfileProvider); final assignmentsAsync = ref.watch(taskAssignmentsProvider); final taskMessagesAsync = ref.watch(taskMessagesProvider(widget.taskId)); final profilesAsync = ref.watch(profilesProvider); - final task = _findTask(tasksAsync, widget.taskId); + // Loading state: use .select() so we only rebuild when the loading flag + // itself changes, not when list data changes. + final isTasksLoading = ref.watch( + tasksProvider.select((a) => !a.hasValue && a.isLoading), + ); + final isTicketsLoading = ref.watch( + ticketsProvider.select((a) => !a.hasValue && a.isLoading), + ); + if (task == null) { return const ResponsiveBody( child: Center(child: Text('Task not found.')), @@ -210,7 +219,7 @@ class _TaskDetailScreenState extends ConsumerState final typingChannelId = task.id; final ticket = ticketId == null ? null - : _findTicket(ticketsAsync, ticketId); + : ref.watch(ticketByIdProvider(ticketId)); final officeById = { for (final office in officesAsync.valueOrNull ?? []) office.id: office, }; @@ -255,8 +264,8 @@ class _TaskDetailScreenState extends ConsumerState final isRetrieving = realtime.isChannelRecovering('tasks') || realtime.isChannelRecovering('task_assignments') || - (!tasksAsync.hasValue && tasksAsync.isLoading) || - (!ticketsAsync.hasValue && ticketsAsync.isLoading) || + isTasksLoading || + isTicketsLoading || (!officesAsync.hasValue && officesAsync.isLoading) || (!profileAsync.hasValue && profileAsync.isLoading) || (!assignmentsAsync.hasValue && assignmentsAsync.isLoading) || @@ -3464,21 +3473,6 @@ class _TaskDetailScreenState extends ConsumerState ); } - Task? _findTask(AsyncValue> tasksAsync, String taskId) { - return tasksAsync.maybeWhen( - data: (tasks) => tasks.where((task) => task.id == taskId).firstOrNull, - orElse: () => null, - ); - } - - Ticket? _findTicket(AsyncValue> ticketsAsync, String ticketId) { - return ticketsAsync.maybeWhen( - data: (tickets) => - tickets.where((ticket) => ticket.id == ticketId).firstOrNull, - orElse: () => null, - ); - } - bool _canAssignStaff(String role) { return role == 'admin' || role == 'dispatcher' || role == 'it_staff'; } diff --git a/lib/screens/tickets/ticket_detail_screen.dart b/lib/screens/tickets/ticket_detail_screen.dart index 073d46ac..df05d8b1 100644 --- a/lib/screens/tickets/ticket_detail_screen.dart +++ b/lib/screens/tickets/ticket_detail_screen.dart @@ -6,7 +6,6 @@ import 'package:supabase_flutter/supabase_flutter.dart'; import '../../models/office.dart'; import '../../models/profile.dart'; -import '../../models/task.dart'; import '../../models/ticket.dart'; import '../../models/ticket_message.dart'; import '../../providers/notifications_provider.dart'; @@ -57,12 +56,13 @@ class _TicketDetailScreenState extends ConsumerState { @override Widget build(BuildContext context) { - final ticket = _findTicket(ref, widget.ticketId); + // Use per-item providers to avoid rebuilding when unrelated tickets/tasks + // change. Only the specific ticket for this screen triggers rebuilds. + final ticket = ref.watch(ticketByIdProvider(widget.ticketId)); final messagesAsync = ref.watch(ticketMessagesProvider(widget.ticketId)); final profilesAsync = ref.watch(profilesProvider); final officesAsync = ref.watch(officesProvider); final currentProfileAsync = ref.watch(currentProfileProvider); - final tasksAsync = ref.watch(tasksProvider); final typingState = ref.watch(typingIndicatorProvider(widget.ticketId)); final canPromote = currentProfileAsync.maybeWhen( data: (profile) => profile != null && _canPromote(profile.role), @@ -76,7 +76,7 @@ class _TicketDetailScreenState extends ConsumerState { final showAssign = canAssign && ticket?.status != 'closed'; final taskForTicket = ticket == null ? null - : _findTaskForTicket(tasksAsync, ticket.id); + : ref.watch(taskByTicketIdProvider(ticket.id)); final hasStaffMessage = _hasStaffMessage( messagesAsync.valueOrNull ?? const [], profilesAsync.valueOrNull ?? const [], @@ -477,15 +477,6 @@ class _TicketDetailScreenState extends ConsumerState { return 'Filed by: $name'; } - Ticket? _findTicket(WidgetRef ref, String ticketId) { - final ticketsAsync = ref.watch(ticketsProvider); - return ticketsAsync.maybeWhen( - data: (tickets) => - tickets.where((ticket) => ticket.id == ticketId).firstOrNull, - orElse: () => null, - ); - } - bool _hasStaffMessage(List messages, List profiles) { if (messages.isEmpty || profiles.isEmpty) { return false; @@ -508,14 +499,6 @@ class _TicketDetailScreenState extends ConsumerState { ); } - Task? _findTaskForTicket(AsyncValue> tasksAsync, String ticketId) { - return tasksAsync.maybeWhen( - data: (tasks) => - tasks.where((task) => task.ticketId == ticketId).firstOrNull, - orElse: () => null, - ); - } - Future _handleSendMessage( WidgetRef ref, List profiles,