Moved long running task to an isolate

This commit is contained in:
Marc Rejohn Castillano 2026-02-28 22:05:28 +08:00
parent 1ce38618f6
commit f7f22c50d2
2 changed files with 272 additions and 173 deletions

View File

@ -139,69 +139,107 @@ final tasksProvider = StreamProvider<List<Task>>((ref) {
// NOTE: Supabase stream builder does not support `.range(...)`
// apply pagination and remaining filters client-side after mapping.
final baseStream = client
.from('tasks')
.stream(primaryKey: ['id'])
.map((rows) => rows.map(Task.fromMap).toList());
final baseStream = client.from('tasks').stream(primaryKey: ['id']);
return baseStream.asyncMap((rows) async {
final rowsList = (rows as List<dynamic>).cast<Map<String, dynamic>>();
return baseStream.map((allTasks) {
debugPrint('[tasksProvider] stream event: ${allTasks.length} rows');
// RBAC (server-side filtering isn't possible via `.range` on stream builder,
// so enforce allowed IDs here).
var list = allTasks;
if (!isGlobal) {
final allowedTicketIds =
ticketsAsync.valueOrNull?.map((ticket) => ticket.id).toList() ??
<String>[];
final officeIds =
final allowedOfficeIds =
assignmentsAsync.valueOrNull
?.where((assignment) => assignment.userId == profile.id)
.map((assignment) => assignment.officeId)
.toSet()
.toList() ??
<String>[];
if (allowedTicketIds.isEmpty && officeIds.isEmpty) return <Task>[];
final allowedTickets = allowedTicketIds.toSet();
final allowedOffices = officeIds.toSet();
final payload = <String, dynamic>{
'rows': rowsList,
'isGlobal': isGlobal,
'allowedTicketIds': allowedTicketIds,
'allowedOfficeIds': allowedOfficeIds,
'officeId': query.officeId,
'status': query.status,
'searchQuery': query.searchQuery,
'taskNumber': query.taskNumber,
'dateStart': query.dateRange?.start.millisecondsSinceEpoch,
'dateEnd': query.dateRange?.end.millisecondsSinceEpoch,
};
final processed = await compute(_processTasksInIsolate, payload);
final tasks = (processed as List<dynamic>)
.cast<Map<String, dynamic>>()
.map(Task.fromMap)
.toList();
debugPrint('[tasksProvider] processed ${tasks.length} tasks');
return tasks;
});
});
// Runs inside a background isolate to filter/sort tasks represented as
// plain maps. Returns a List<Map<String,dynamic>> suitable for
// reconstruction with `Task.fromMap` on the main isolate.
List<Map<String, dynamic>> _processTasksInIsolate(
Map<String, dynamic> payload,
) {
var list = List<Map<String, dynamic>>.from(
(payload['rows'] as List).cast<Map<String, dynamic>>(),
);
final isGlobal = payload['isGlobal'] as bool? ?? false;
final allowedTicketIds =
(payload['allowedTicketIds'] as List?)?.cast<String>().toSet() ??
<String>{};
final allowedOfficeIds =
(payload['allowedOfficeIds'] as List?)?.cast<String>().toSet() ??
<String>{};
if (!isGlobal) {
if (allowedTicketIds.isEmpty && allowedOfficeIds.isEmpty)
return <Map<String, dynamic>>[];
list = list.where((t) {
final tid = t['ticket_id'] as String?;
final oid = t['office_id'] as String?;
return (tid != null && allowedTicketIds.contains(tid)) ||
(oid != null && allowedOfficeIds.contains(oid));
}).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 title = (t['title'] as String?)?.toLowerCase() ?? '';
final desc = (t['description'] as String?)?.toLowerCase() ?? '';
final tn = (t['task_number'] as String?)?.toLowerCase() ?? '';
return title.contains(q) || desc.contains(q) || tn.contains(q);
}).toList();
}
final taskNumberFilter = (payload['taskNumber'] as String?)?.trim();
if (taskNumberFilter != null && taskNumberFilter.isNotEmpty) {
final tnLow = taskNumberFilter.toLowerCase();
list = list
.where(
(t) =>
(t.ticketId != null && allowedTickets.contains(t.ticketId)) ||
(t.officeId != null && allowedOffices.contains(t.officeId)),
(t) => ((t['task_number'] as String?) ?? '').toLowerCase().contains(
tnLow,
),
)
.toList();
}
// Query filters (apply client-side)
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.title.toLowerCase().contains(q) ||
t.description.toLowerCase().contains(q) ||
(t.taskNumber?.toLowerCase().contains(q) ?? false),
)
.toList();
}
if (query.taskNumber != null && query.taskNumber!.trim().isNotEmpty) {
final tn = query.taskNumber!.toLowerCase();
list = list
.where((t) => (t.taskNumber ?? '').toLowerCase().contains(tn))
.toList();
}
// Sort by status groups then within-group ordering:
// 1. queued order by priority (desc), then queue_order (asc), then created_at
// 2. in_progress preserve recent order (created_at asc)
// 3. completed order by numeric task_number when available (asc)
// 4. other statuses fallback to queue_order then created_at
int statusRank(String s) {
switch (s) {
case 'queued':
@ -217,65 +255,69 @@ final tasksProvider = StreamProvider<List<Task>>((ref) {
}
}
int? parseTaskNumber(Task t) {
final tn = t.taskNumber;
int? parseTaskNumberFromString(String? tn) {
if (tn == null) return null;
final m = RegExp(r'\d+').firstMatch(tn);
if (m == null) return null;
return int.tryParse(m.group(0)!);
}
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) {
final ra = statusRank(a.status);
final rb = statusRank(b.status);
final ra = statusRank((a['status'] as String?) ?? '');
final rb = statusRank((b['status'] as String?) ?? '');
final rcmp = ra.compareTo(rb);
if (rcmp != 0) return rcmp;
// Same status: apply within-group ordering
if (ra == 0) {
// queued: higher priority first, then queue_order asc, then created_at
final pcmp = b.priority.compareTo(a.priority);
final pa = (a['priority'] as num?)?.toInt() ?? 1;
final pb = (b['priority'] as num?)?.toInt() ?? 1;
final pcmp = pb.compareTo(pa);
if (pcmp != 0) return pcmp;
final aOrder = a.queueOrder ?? 0x7fffffff;
final bOrder = b.queueOrder ?? 0x7fffffff;
final aOrder = (a['queue_order'] as int?) ?? 0x7fffffff;
final bOrder = (b['queue_order'] as int?) ?? 0x7fffffff;
final qcmp = aOrder.compareTo(bOrder);
if (qcmp != 0) return qcmp;
return a.createdAt.compareTo(b.createdAt);
return _parseCreatedAt(a).compareTo(_parseCreatedAt(b));
}
if (ra == 1) {
// in_progress: keep older first
return a.createdAt.compareTo(b.createdAt);
return _parseCreatedAt(a).compareTo(_parseCreatedAt(b));
}
if (ra == 2) {
// completed: prefer numeric task_number DESC when present
final an = parseTaskNumber(a);
final bn = parseTaskNumber(b);
final an = parseTaskNumberFromString(a['task_number'] as String?);
final bn = parseTaskNumberFromString(b['task_number'] as String?);
if (an != null && bn != null) return bn.compareTo(an);
if (an != null) return -1;
if (bn != null) return 1;
return b.createdAt.compareTo(a.createdAt);
return _parseCreatedAt(b).compareTo(_parseCreatedAt(a));
}
// fallback: queue_order then created_at
final aOrder = a.queueOrder ?? 0x7fffffff;
final bOrder = b.queueOrder ?? 0x7fffffff;
final aOrder = (a['queue_order'] as int?) ?? 0x7fffffff;
final bOrder = (b['queue_order'] as int?) ?? 0x7fffffff;
final cmp = aOrder.compareTo(bOrder);
if (cmp != 0) return cmp;
return a.createdAt.compareTo(b.createdAt);
return _parseCreatedAt(a).compareTo(_parseCreatedAt(b));
});
// Return the full filtered & sorted list to allow the UI layer to
// perform pagination (desktop PaginatedDataTable expects the full
// row count so it can render pagination controls reliably). The
// Supabase stream currently delivers all rows and the provider
// applies filtering/sorting; leaving pagination to the UI avoids
// off-by-one issues where a full page of results would hide the
// presence of a next page.
return list;
});
});
}
/// Provider for task query parameters.
final tasksQueryProvider = StateProvider<TaskQuery>((ref) => const TaskQuery());

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,57 +128,113 @@ 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 =
// Prepare lightweight serializable args for background processing
final allowedOfficeIds =
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();
}
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();
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);
// `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) =>
t.subject.toLowerCase().contains(q) ||
t.description.toLowerCase().contains(q),
)
.where((t) => allowedOfficeIds.contains(t['office_id']))
.toList();
}
// Sort: newest first
list.sort((a, b) => b.createdAt.compareTo(a.createdAt));
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();
}
// Pagination
final start = query.offset;
final end = (start + query.limit).clamp(0, list.length);
if (start >= list.length) return <Ticket>[];
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>(