Fix flickering issues due to reconnecting loop
This commit is contained in:
parent
5713581992
commit
7115e2df05
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user