Tickets and task retrieval optimization
This commit is contained in:
@@ -120,6 +120,46 @@ class TicketQuery {
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the isolate payload from a list of [Ticket] objects and the current
|
||||
/// query/access context. Extracted so the initial REST seed and the realtime
|
||||
/// stream listener can share the same logic without duplication.
|
||||
Map<String, dynamic> _buildTicketPayload({
|
||||
required List<Ticket> tickets,
|
||||
required bool isGlobal,
|
||||
required List<String> allowedOfficeIds,
|
||||
required TicketQuery query,
|
||||
}) {
|
||||
final rowsList = tickets
|
||||
.map(
|
||||
(ticket) => <String, dynamic>{
|
||||
'id': ticket.id,
|
||||
'subject': ticket.subject,
|
||||
'description': ticket.description,
|
||||
'status': ticket.status,
|
||||
'office_id': ticket.officeId,
|
||||
'creator_id': ticket.creatorId,
|
||||
'created_at': ticket.createdAt.toIso8601String(),
|
||||
'responded_at': ticket.respondedAt?.toIso8601String(),
|
||||
'promoted_at': ticket.promotedAt?.toIso8601String(),
|
||||
'closed_at': ticket.closedAt?.toIso8601String(),
|
||||
},
|
||||
)
|
||||
.toList();
|
||||
|
||||
return {
|
||||
'rows': rowsList,
|
||||
'isGlobal': isGlobal,
|
||||
'allowedOfficeIds': allowedOfficeIds,
|
||||
'offset': query.offset,
|
||||
'limit': query.limit,
|
||||
'searchQuery': query.searchQuery,
|
||||
'officeId': query.officeId,
|
||||
'status': query.status,
|
||||
'dateStart': query.dateRange?.start.millisecondsSinceEpoch,
|
||||
'dateEnd': query.dateRange?.end.millisecondsSinceEpoch,
|
||||
};
|
||||
}
|
||||
|
||||
final ticketsProvider = StreamProvider<List<Ticket>>((ref) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
final profileAsync = ref.watch(currentProfileProvider);
|
||||
@@ -136,11 +176,17 @@ final ticketsProvider = StreamProvider<List<Ticket>>((ref) {
|
||||
profile.role == 'dispatcher' ||
|
||||
profile.role == 'it_staff';
|
||||
|
||||
final allowedOfficeIds =
|
||||
assignmentsAsync.valueOrNull
|
||||
?.where((a) => a.userId == profile.id)
|
||||
.map((a) => a.officeId)
|
||||
.toList() ??
|
||||
<String>[];
|
||||
|
||||
// Wrap realtime stream with recovery logic
|
||||
final wrapper = StreamRecoveryWrapper<Ticket>(
|
||||
stream: client.from('tickets').stream(primaryKey: ['id']),
|
||||
onPollData: () async {
|
||||
// Polling fallback: fetch all tickets once
|
||||
final data = await client.from('tickets').select();
|
||||
return data.cast<Map<String, dynamic>>().map(Ticket.fromMap).toList();
|
||||
},
|
||||
@@ -149,55 +195,91 @@ final ticketsProvider = StreamProvider<List<Ticket>>((ref) {
|
||||
|
||||
ref.onDispose(wrapper.dispose);
|
||||
|
||||
// Process tickets with filtering/pagination after recovery
|
||||
return wrapper.stream.asyncMap((result) async {
|
||||
final rowsList = result.data
|
||||
.map(
|
||||
(ticket) => <String, dynamic>{
|
||||
'id': ticket.id,
|
||||
'subject': ticket.subject,
|
||||
'description': ticket.description,
|
||||
'status': ticket.status,
|
||||
'office_id': ticket.officeId,
|
||||
'creator_id': ticket.creatorId,
|
||||
'created_at': ticket.createdAt.toIso8601String(),
|
||||
'responded_at': ticket.respondedAt?.toIso8601String(),
|
||||
'promoted_at': ticket.promotedAt?.toIso8601String(),
|
||||
'closed_at': ticket.closedAt?.toIso8601String(),
|
||||
},
|
||||
)
|
||||
.toList();
|
||||
var lastResultHash = '';
|
||||
Timer? debounceTimer;
|
||||
// broadcast() so Riverpod and any other listener can both receive events.
|
||||
final controller = StreamController<List<Ticket>>.broadcast();
|
||||
|
||||
final allowedOfficeIds =
|
||||
assignmentsAsync.valueOrNull
|
||||
?.where((assignment) => assignment.userId == profile.id)
|
||||
.map((assignment) => assignment.officeId)
|
||||
.toList() ??
|
||||
<String>[];
|
||||
void emitDebounced(List<Ticket> tickets) {
|
||||
debounceTimer?.cancel();
|
||||
debounceTimer = Timer(const Duration(milliseconds: 150), () {
|
||||
if (!controller.isClosed) controller.add(tickets);
|
||||
});
|
||||
}
|
||||
|
||||
final payload = <String, dynamic>{
|
||||
'rows': rowsList,
|
||||
'isGlobal': isGlobal,
|
||||
'allowedOfficeIds': allowedOfficeIds,
|
||||
'offset': query.offset,
|
||||
'limit': query.limit,
|
||||
'searchQuery': query.searchQuery,
|
||||
'officeId': query.officeId,
|
||||
'status': query.status,
|
||||
'dateStart': query.dateRange?.start.millisecondsSinceEpoch,
|
||||
'dateEnd': query.dateRange?.end.millisecondsSinceEpoch,
|
||||
};
|
||||
|
||||
final processed = await compute(_processTicketsInIsolate, payload);
|
||||
|
||||
final tickets = (processed as List<dynamic>)
|
||||
.cast<Map<String, dynamic>>()
|
||||
.map(Ticket.fromMap)
|
||||
.toList();
|
||||
|
||||
debugPrint('[ticketsProvider] processed ${tickets.length} tickets');
|
||||
return tickets;
|
||||
ref.onDispose(() {
|
||||
debounceTimer?.cancel();
|
||||
controller.close();
|
||||
});
|
||||
|
||||
// ── Immediate REST seed ───────────────────────────────────────────────────
|
||||
// Fire a one-shot HTTP fetch right now so the UI can render before the
|
||||
// WebSocket realtime channel is fully established. This eliminates the
|
||||
// loading delay on web (WebSocket ~200-500 ms) and the initial flash on
|
||||
// mobile. The realtime stream takes over afterwards; the hash check below
|
||||
// prevents a duplicate rebuild if both arrive with identical data.
|
||||
unawaited(
|
||||
Future(() async {
|
||||
try {
|
||||
final data = await client.from('tickets').select();
|
||||
final raw = data
|
||||
.cast<Map<String, dynamic>>()
|
||||
.map(Ticket.fromMap)
|
||||
.toList();
|
||||
final payload = _buildTicketPayload(
|
||||
tickets: raw,
|
||||
isGlobal: isGlobal,
|
||||
allowedOfficeIds: allowedOfficeIds,
|
||||
query: query,
|
||||
);
|
||||
final processed = await compute(_processTicketsInIsolate, payload);
|
||||
final tickets = (processed as List<dynamic>)
|
||||
.cast<Map<String, dynamic>>()
|
||||
.map(Ticket.fromMap)
|
||||
.toList();
|
||||
final hash = tickets.fold('', (h, t) => '$h${t.id}');
|
||||
if (!controller.isClosed && hash != lastResultHash) {
|
||||
lastResultHash = hash;
|
||||
controller.add(tickets); // emit immediately – no debounce
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[ticketsProvider] initial seed error: $e');
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// ── Realtime stream ───────────────────────────────────────────────────────
|
||||
// Processes every realtime event through the same isolate. Debounced so
|
||||
// rapid consecutive events (e.g. bulk inserts) don't cause repeated renders.
|
||||
wrapper.stream
|
||||
.asyncMap((result) async {
|
||||
final payload = _buildTicketPayload(
|
||||
tickets: result.data,
|
||||
isGlobal: isGlobal,
|
||||
allowedOfficeIds: allowedOfficeIds,
|
||||
query: query,
|
||||
);
|
||||
final processed = await compute(_processTicketsInIsolate, payload);
|
||||
return (processed as List<dynamic>)
|
||||
.cast<Map<String, dynamic>>()
|
||||
.map(Ticket.fromMap)
|
||||
.toList();
|
||||
})
|
||||
.listen(
|
||||
(tickets) {
|
||||
final hash = tickets.fold('', (h, t) => '$h${t.id}');
|
||||
if (hash != lastResultHash) {
|
||||
lastResultHash = hash;
|
||||
emitDebounced(tickets);
|
||||
}
|
||||
},
|
||||
onError: (Object e) {
|
||||
debugPrint('[ticketsProvider] stream error: $e');
|
||||
controller.addError(e);
|
||||
},
|
||||
);
|
||||
|
||||
return controller.stream;
|
||||
});
|
||||
|
||||
// Runs inside a background isolate. Accepts a serializable payload and
|
||||
|
||||
Reference in New Issue
Block a user