Fix flickering issues due to reconnecting loop

This commit is contained in:
Marc Rejohn Castillano 2026-03-02 22:09:29 +08:00
parent 5713581992
commit 7115e2df05
8 changed files with 171 additions and 45 deletions

View File

@ -59,6 +59,59 @@ class Task {
final String? cancellationReason; final String? cancellationReason;
final DateTime? cancelledAt; 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 /// Helper that indicates whether a completed task still has missing
/// metadata such as signatories or action details. The parameter is used /// 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 /// by UI to surface a warning icon/banner when a task has been closed but

View File

@ -25,6 +25,36 @@ class Ticket {
final DateTime? promotedAt; final DateTime? promotedAt;
final DateTime? closedAt; 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<String, dynamic> map) { factory Ticket.fromMap(Map<String, dynamic> map) {
return Ticket( return Ticket(
id: map['id'] as String, id: map['id'] as String,

View File

@ -94,8 +94,12 @@ class RealtimeController extends ChangeNotifier {
/// Convenience callback suitable for [StreamRecoveryWrapper.onStatusChanged]. /// Convenience callback suitable for [StreamRecoveryWrapper.onStatusChanged].
/// ///
/// Routes [StreamConnectionStatus] to the appropriate mark method. /// 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) { void handleChannelStatus(String channel, StreamConnectionStatus status) {
if (status == StreamConnectionStatus.connected) { if (status == StreamConnectionStatus.connected ||
status == StreamConnectionStatus.polling) {
markChannelRecovered(channel); markChannelRecovered(channel);
} else { } else {
markChannelRecovering(channel); markChannelRecovering(channel);

View File

@ -109,6 +109,7 @@ class StreamRecoveryWrapper<T> {
int _recoveryAttempts = 0; int _recoveryAttempts = 0;
Timer? _pollingTimer; Timer? _pollingTimer;
Timer? _recoveryTimer; Timer? _recoveryTimer;
Timer? _stabilityTimer;
StreamSubscription<List<Map<String, dynamic>>>? _realtimeSub; StreamSubscription<List<Map<String, dynamic>>>? _realtimeSub;
StreamController<StreamRecoveryResult<T>>? _controller; StreamController<StreamRecoveryResult<T>>? _controller;
bool _disposed = false; bool _disposed = false;
@ -159,8 +160,28 @@ class StreamRecoveryWrapper<T> {
void _onRealtimeData(List<Map<String, dynamic>> rows) { void _onRealtimeData(List<Map<String, dynamic>> rows) {
if (_disposed) return; 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); _setStatus(StreamConnectionStatus.connected);
_emit( _emit(
StreamRecoveryResult<T>( StreamRecoveryResult<T>(
@ -174,6 +195,9 @@ class StreamRecoveryWrapper<T> {
void _onRealtimeError(Object error, [StackTrace? stack]) { void _onRealtimeError(Object error, [StackTrace? stack]) {
if (_disposed) return; if (_disposed) return;
// Cancel any stability timer the connection is not stable.
_stabilityTimer?.cancel();
final isRateLimit = _isRateLimitError(error); final isRateLimit = _isRateLimitError(error);
final isTimeout = _isTimeoutError(error); final isTimeout = _isTimeoutError(error);
final tag = isRateLimit final tag = isRateLimit
@ -337,6 +361,7 @@ class StreamRecoveryWrapper<T> {
_disposed = true; _disposed = true;
_pollingTimer?.cancel(); _pollingTimer?.cancel();
_recoveryTimer?.cancel(); _recoveryTimer?.cancel();
_stabilityTimer?.cancel();
_realtimeSub?.cancel(); _realtimeSub?.cancel();
// Ensure the channel is removed from the recovering set when the // Ensure the channel is removed from the recovering set when the
// wrapper is torn down (e.g. provider disposed during navigation). // wrapper is torn down (e.g. provider disposed during navigation).

View File

@ -526,6 +526,30 @@ List<Map<String, dynamic>> _processTasksInIsolate(
/// Provider for task query parameters. /// Provider for task query parameters.
final tasksQueryProvider = StateProvider<TaskQuery>((ref) => const TaskQuery()); final tasksQueryProvider = StateProvider<TaskQuery>((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<Task?, String>((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<Task?, String>((ref, ticketId) {
return ref
.watch(tasksProvider)
.valueOrNull
?.where((t) => t.ticketId == ticketId)
.firstOrNull;
});
final taskAssignmentsProvider = StreamProvider<List<TaskAssignment>>((ref) { final taskAssignmentsProvider = StreamProvider<List<TaskAssignment>>((ref) {
final client = ref.watch(supabaseClientProvider); final client = ref.watch(supabaseClientProvider);

View File

@ -361,6 +361,19 @@ final ticketsQueryProvider = StateProvider<TicketQuery>(
(ref) => const TicketQuery(), (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<Ticket?, String>((ref, ticketId) {
return ref
.watch(ticketsProvider)
.valueOrNull
?.where((t) => t.id == ticketId)
.firstOrNull;
});
final ticketMessagesProvider = final ticketMessagesProvider =
StreamProvider.family<List<TicketMessage>, String>((ref, ticketId) { StreamProvider.family<List<TicketMessage>, String>((ref, ticketId) {
final client = ref.watch(supabaseClientProvider); final client = ref.watch(supabaseClientProvider);

View File

@ -192,15 +192,24 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final tasksAsync = ref.watch(tasksProvider); // Use per-item providers to avoid rebuilding when unrelated tasks/tickets
final ticketsAsync = ref.watch(ticketsProvider); // change. Only the specific task/ticket for this screen triggers rebuilds.
final task = ref.watch(taskByIdProvider(widget.taskId));
final officesAsync = ref.watch(officesProvider); final officesAsync = ref.watch(officesProvider);
final profileAsync = ref.watch(currentProfileProvider); final profileAsync = ref.watch(currentProfileProvider);
final assignmentsAsync = ref.watch(taskAssignmentsProvider); final assignmentsAsync = ref.watch(taskAssignmentsProvider);
final taskMessagesAsync = ref.watch(taskMessagesProvider(widget.taskId)); final taskMessagesAsync = ref.watch(taskMessagesProvider(widget.taskId));
final profilesAsync = ref.watch(profilesProvider); 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) { if (task == null) {
return const ResponsiveBody( return const ResponsiveBody(
child: Center(child: Text('Task not found.')), child: Center(child: Text('Task not found.')),
@ -210,7 +219,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
final typingChannelId = task.id; final typingChannelId = task.id;
final ticket = ticketId == null final ticket = ticketId == null
? null ? null
: _findTicket(ticketsAsync, ticketId); : ref.watch(ticketByIdProvider(ticketId));
final officeById = { final officeById = {
for (final office in officesAsync.valueOrNull ?? []) office.id: office, for (final office in officesAsync.valueOrNull ?? []) office.id: office,
}; };
@ -255,8 +264,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
final isRetrieving = final isRetrieving =
realtime.isChannelRecovering('tasks') || realtime.isChannelRecovering('tasks') ||
realtime.isChannelRecovering('task_assignments') || realtime.isChannelRecovering('task_assignments') ||
(!tasksAsync.hasValue && tasksAsync.isLoading) || isTasksLoading ||
(!ticketsAsync.hasValue && ticketsAsync.isLoading) || isTicketsLoading ||
(!officesAsync.hasValue && officesAsync.isLoading) || (!officesAsync.hasValue && officesAsync.isLoading) ||
(!profileAsync.hasValue && profileAsync.isLoading) || (!profileAsync.hasValue && profileAsync.isLoading) ||
(!assignmentsAsync.hasValue && assignmentsAsync.isLoading) || (!assignmentsAsync.hasValue && assignmentsAsync.isLoading) ||
@ -3464,21 +3473,6 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
); );
} }
Task? _findTask(AsyncValue<List<Task>> tasksAsync, String taskId) {
return tasksAsync.maybeWhen(
data: (tasks) => tasks.where((task) => task.id == taskId).firstOrNull,
orElse: () => null,
);
}
Ticket? _findTicket(AsyncValue<List<Ticket>> ticketsAsync, String ticketId) {
return ticketsAsync.maybeWhen(
data: (tickets) =>
tickets.where((ticket) => ticket.id == ticketId).firstOrNull,
orElse: () => null,
);
}
bool _canAssignStaff(String role) { bool _canAssignStaff(String role) {
return role == 'admin' || role == 'dispatcher' || role == 'it_staff'; return role == 'admin' || role == 'dispatcher' || role == 'it_staff';
} }

View File

@ -6,7 +6,6 @@ import 'package:supabase_flutter/supabase_flutter.dart';
import '../../models/office.dart'; import '../../models/office.dart';
import '../../models/profile.dart'; import '../../models/profile.dart';
import '../../models/task.dart';
import '../../models/ticket.dart'; import '../../models/ticket.dart';
import '../../models/ticket_message.dart'; import '../../models/ticket_message.dart';
import '../../providers/notifications_provider.dart'; import '../../providers/notifications_provider.dart';
@ -57,12 +56,13 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
@override @override
Widget build(BuildContext context) { 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 messagesAsync = ref.watch(ticketMessagesProvider(widget.ticketId));
final profilesAsync = ref.watch(profilesProvider); final profilesAsync = ref.watch(profilesProvider);
final officesAsync = ref.watch(officesProvider); final officesAsync = ref.watch(officesProvider);
final currentProfileAsync = ref.watch(currentProfileProvider); final currentProfileAsync = ref.watch(currentProfileProvider);
final tasksAsync = ref.watch(tasksProvider);
final typingState = ref.watch(typingIndicatorProvider(widget.ticketId)); final typingState = ref.watch(typingIndicatorProvider(widget.ticketId));
final canPromote = currentProfileAsync.maybeWhen( final canPromote = currentProfileAsync.maybeWhen(
data: (profile) => profile != null && _canPromote(profile.role), data: (profile) => profile != null && _canPromote(profile.role),
@ -76,7 +76,7 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
final showAssign = canAssign && ticket?.status != 'closed'; final showAssign = canAssign && ticket?.status != 'closed';
final taskForTicket = ticket == null final taskForTicket = ticket == null
? null ? null
: _findTaskForTicket(tasksAsync, ticket.id); : ref.watch(taskByTicketIdProvider(ticket.id));
final hasStaffMessage = _hasStaffMessage( final hasStaffMessage = _hasStaffMessage(
messagesAsync.valueOrNull ?? const [], messagesAsync.valueOrNull ?? const [],
profilesAsync.valueOrNull ?? const [], profilesAsync.valueOrNull ?? const [],
@ -477,15 +477,6 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
return 'Filed by: $name'; 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<TicketMessage> messages, List<Profile> profiles) { bool _hasStaffMessage(List<TicketMessage> messages, List<Profile> profiles) {
if (messages.isEmpty || profiles.isEmpty) { if (messages.isEmpty || profiles.isEmpty) {
return false; return false;
@ -508,14 +499,6 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
); );
} }
Task? _findTaskForTicket(AsyncValue<List<Task>> tasksAsync, String ticketId) {
return tasksAsync.maybeWhen(
data: (tasks) =>
tasks.where((task) => task.ticketId == ticketId).firstOrNull,
orElse: () => null,
);
}
Future<void> _handleSendMessage( Future<void> _handleSendMessage(
WidgetRef ref, WidgetRef ref,
List<Profile> profiles, List<Profile> profiles,