Moved long running task to an isolate

This commit is contained in:
2026-02-28 22:05:28 +08:00
parent 1ce38618f6
commit f7f22c50d2
2 changed files with 272 additions and 173 deletions
+101 -44
View File
@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import '../models/office.dart';
import '../models/ticket.dart';
@@ -127,58 +128,114 @@ final ticketsProvider = StreamProvider<List<Ticket>>((ref) {
profile.role == 'dispatcher' ||
profile.role == 'it_staff';
// Use stream for realtime updates, then apply pagination & search filters
// client-side because `.range(...)` is not supported on the stream builder.
final baseStream = client
.from('tickets')
.stream(primaryKey: ['id'])
.map((rows) => rows.map(Ticket.fromMap).toList());
// Use stream for realtime updates. Offload expensive client-side
// filtering/sorting/pagination to a background isolate via `compute`
// so UI navigation and builds remain smooth.
final baseStream = client.from('tickets').stream(primaryKey: ['id']);
return baseStream.map((allTickets) {
debugPrint('[ticketsProvider] stream event: ${allTickets.length} rows');
var list = allTickets;
return baseStream.asyncMap((rows) async {
// rows is List<dynamic> of maps coming from Supabase
final rowsList = (rows as List<dynamic>).cast<Map<String, dynamic>>();
if (!isGlobal) {
final officeIds =
assignmentsAsync.valueOrNull
?.where((assignment) => assignment.userId == profile.id)
.map((assignment) => assignment.officeId)
.toSet()
.toList() ??
<String>[];
if (officeIds.isEmpty) return <Ticket>[];
final allowedOffices = officeIds.toSet();
list = list.where((t) => allowedOffices.contains(t.officeId)).toList();
}
// Prepare lightweight serializable args for background processing
final allowedOfficeIds =
assignmentsAsync.valueOrNull
?.where((assignment) => assignment.userId == profile.id)
.map((assignment) => assignment.officeId)
.toList() ??
<String>[];
if (query.officeId != null) {
list = list.where((t) => t.officeId == query.officeId).toList();
}
if (query.status != null) {
list = list.where((t) => t.status == query.status).toList();
}
if (query.searchQuery.isNotEmpty) {
final q = query.searchQuery.toLowerCase();
list = list
.where(
(t) =>
t.subject.toLowerCase().contains(q) ||
t.description.toLowerCase().contains(q),
)
.toList();
}
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,
};
// Sort: newest first
list.sort((a, b) => b.createdAt.compareTo(a.createdAt));
final processed = await compute(_processTicketsInIsolate, payload);
// Pagination
final start = query.offset;
final end = (start + query.limit).clamp(0, list.length);
if (start >= list.length) return <Ticket>[];
return list.sublist(start, end);
// `processed` is List<Map<String,dynamic>> — convert to Ticket objects
final tickets = (processed as List<dynamic>)
.cast<Map<String, dynamic>>()
.map(Ticket.fromMap)
.toList();
debugPrint('[ticketsProvider] processed ${tickets.length} tickets');
return tickets;
});
});
// Runs inside a background isolate. Accepts a serializable payload and
// returns a list of ticket maps after filtering/sorting/pagination.
List<Map<String, dynamic>> _processTicketsInIsolate(
Map<String, dynamic> payload,
) {
final rows = (payload['rows'] as List).cast<Map<String, dynamic>>();
var list = List<Map<String, dynamic>>.from(rows);
final isGlobal = payload['isGlobal'] as bool? ?? false;
final allowedOfficeIds =
(payload['allowedOfficeIds'] as List?)?.cast<String>().toSet() ??
<String>{};
if (!isGlobal) {
if (allowedOfficeIds.isEmpty) return <Map<String, dynamic>>[];
list = list
.where((t) => allowedOfficeIds.contains(t['office_id']))
.toList();
}
final officeId = payload['officeId'] as String?;
if (officeId != null) {
list = list.where((t) => t['office_id'] == officeId).toList();
}
final status = payload['status'] as String?;
if (status != null) {
list = list.where((t) => t['status'] == status).toList();
}
final searchQuery = (payload['searchQuery'] as String?) ?? '';
if (searchQuery.isNotEmpty) {
final q = searchQuery.toLowerCase();
list = list.where((t) {
final subj = (t['subject'] as String?)?.toLowerCase() ?? '';
final desc = (t['description'] as String?)?.toLowerCase() ?? '';
return subj.contains(q) || desc.contains(q);
}).toList();
}
// Sort newest first. `created_at` may be ISO strings or timestamps;
// handle strings and numeric values.
int _parseCreatedAt(Map<String, dynamic> m) {
final v = m['created_at'];
if (v == null) return 0;
if (v is int) return v;
if (v is double) return v.toInt();
if (v is String) {
try {
return DateTime.parse(v).millisecondsSinceEpoch;
} catch (_) {
return 0;
}
}
return 0;
}
list.sort((a, b) => _parseCreatedAt(b).compareTo(_parseCreatedAt(a)));
final start = (payload['offset'] as int?) ?? 0;
final limit = (payload['limit'] as int?) ?? 50;
final end = (start + limit).clamp(0, list.length);
if (start >= list.length) return <Map<String, dynamic>>[];
return list.sublist(start, end);
}
/// Provider for ticket query parameters.
final ticketsQueryProvider = StateProvider<TicketQuery>(
(ref) => const TicketQuery(),