From f7f22c50d2e8597f92f64815185ac722a29e944b Mon Sep 17 00:00:00 2001 From: Marc Rejohn Castillano Date: Sat, 28 Feb 2026 22:05:28 +0800 Subject: [PATCH] Moved long running task to an isolate --- lib/providers/tasks_provider.dart | 300 ++++++++++++++++------------ lib/providers/tickets_provider.dart | 145 ++++++++++---- 2 files changed, 272 insertions(+), 173 deletions(-) diff --git a/lib/providers/tasks_provider.dart b/lib/providers/tasks_provider.dart index 083901d6..513dbff5 100644 --- a/lib/providers/tasks_provider.dart +++ b/lib/providers/tasks_provider.dart @@ -139,144 +139,186 @@ final tasksProvider = StreamProvider>((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.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() ?? - []; - final officeIds = - assignmentsAsync.valueOrNull - ?.where((assignment) => assignment.userId == profile.id) - .map((assignment) => assignment.officeId) - .toSet() - .toList() ?? - []; - if (allowedTicketIds.isEmpty && officeIds.isEmpty) return []; - final allowedTickets = allowedTicketIds.toSet(); - final allowedOffices = officeIds.toSet(); - list = list - .where( - (t) => - (t.ticketId != null && allowedTickets.contains(t.ticketId)) || - (t.officeId != null && allowedOffices.contains(t.officeId)), - ) - .toList(); - } + return baseStream.asyncMap((rows) async { + final rowsList = (rows as List).cast>(); - // 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(); - } + final allowedTicketIds = + ticketsAsync.valueOrNull?.map((ticket) => ticket.id).toList() ?? + []; + final allowedOfficeIds = + assignmentsAsync.valueOrNull + ?.where((assignment) => assignment.userId == profile.id) + .map((assignment) => assignment.officeId) + .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': - return 0; - case 'in_progress': - return 1; - case 'completed': - return 2; - case 'cancelled': - return 3; - default: - return 4; - } - } + final payload = { + '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, + }; - int? parseTaskNumber(Task t) { - final tn = t.taskNumber; - if (tn == null) return null; - final m = RegExp(r'\d+').firstMatch(tn); - if (m == null) return null; - return int.tryParse(m.group(0)!); - } + final processed = await compute(_processTasksInIsolate, payload); - list.sort((a, b) { - final ra = statusRank(a.status); - final rb = statusRank(b.status); - final rcmp = ra.compareTo(rb); - if (rcmp != 0) return rcmp; + final tasks = (processed as List) + .cast>() + .map(Task.fromMap) + .toList(); - // 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); - if (pcmp != 0) return pcmp; - final aOrder = a.queueOrder ?? 0x7fffffff; - final bOrder = b.queueOrder ?? 0x7fffffff; - final qcmp = aOrder.compareTo(bOrder); - if (qcmp != 0) return qcmp; - return a.createdAt.compareTo(b.createdAt); - } - - if (ra == 1) { - // in_progress: keep older first - return a.createdAt.compareTo(b.createdAt); - } - - if (ra == 2) { - // completed: prefer numeric task_number DESC when present - final an = parseTaskNumber(a); - final bn = parseTaskNumber(b); - 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); - } - - // fallback: queue_order then created_at - final aOrder = a.queueOrder ?? 0x7fffffff; - final bOrder = b.queueOrder ?? 0x7fffffff; - final cmp = aOrder.compareTo(bOrder); - if (cmp != 0) return cmp; - return a.createdAt.compareTo(b.createdAt); - }); - - // 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; + debugPrint('[tasksProvider] processed ${tasks.length} tasks'); + return tasks; }); }); +// Runs inside a background isolate to filter/sort tasks represented as +// plain maps. Returns a List> suitable for +// reconstruction with `Task.fromMap` on the main isolate. +List> _processTasksInIsolate( + Map payload, +) { + var list = List>.from( + (payload['rows'] as List).cast>(), + ); + + final isGlobal = payload['isGlobal'] as bool? ?? false; + final allowedTicketIds = + (payload['allowedTicketIds'] as List?)?.cast().toSet() ?? + {}; + final allowedOfficeIds = + (payload['allowedOfficeIds'] as List?)?.cast().toSet() ?? + {}; + + if (!isGlobal) { + if (allowedTicketIds.isEmpty && allowedOfficeIds.isEmpty) + return >[]; + 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['task_number'] as String?) ?? '').toLowerCase().contains( + tnLow, + ), + ) + .toList(); + } + + int statusRank(String s) { + switch (s) { + case 'queued': + return 0; + case 'in_progress': + return 1; + case 'completed': + return 2; + case 'cancelled': + return 3; + default: + return 4; + } + } + + 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 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'] as String?) ?? ''); + final rb = statusRank((b['status'] as String?) ?? ''); + final rcmp = ra.compareTo(rb); + if (rcmp != 0) return rcmp; + + if (ra == 0) { + // queued: higher priority first, then queue_order asc, then created_at + 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['queue_order'] as int?) ?? 0x7fffffff; + final bOrder = (b['queue_order'] as int?) ?? 0x7fffffff; + final qcmp = aOrder.compareTo(bOrder); + if (qcmp != 0) return qcmp; + return _parseCreatedAt(a).compareTo(_parseCreatedAt(b)); + } + + if (ra == 1) { + return _parseCreatedAt(a).compareTo(_parseCreatedAt(b)); + } + + if (ra == 2) { + 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 _parseCreatedAt(b).compareTo(_parseCreatedAt(a)); + } + + 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 _parseCreatedAt(a).compareTo(_parseCreatedAt(b)); + }); + + return list; +} + /// Provider for task query parameters. final tasksQueryProvider = StateProvider((ref) => const TaskQuery()); diff --git a/lib/providers/tickets_provider.dart b/lib/providers/tickets_provider.dart index e2a7386e..e3086437 100644 --- a/lib/providers/tickets_provider.dart +++ b/lib/providers/tickets_provider.dart @@ -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>((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 of maps coming from Supabase + final rowsList = (rows as List).cast>(); - if (!isGlobal) { - final officeIds = - assignmentsAsync.valueOrNull - ?.where((assignment) => assignment.userId == profile.id) - .map((assignment) => assignment.officeId) - .toSet() - .toList() ?? - []; - if (officeIds.isEmpty) return []; - 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() ?? + []; - 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 = { + '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 []; - return list.sublist(start, end); + // `processed` is List> — convert to Ticket objects + final tickets = (processed as List) + .cast>() + .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> _processTicketsInIsolate( + Map payload, +) { + final rows = (payload['rows'] as List).cast>(); + var list = List>.from(rows); + + final isGlobal = payload['isGlobal'] as bool? ?? false; + final allowedOfficeIds = + (payload['allowedOfficeIds'] as List?)?.cast().toSet() ?? + {}; + + if (!isGlobal) { + if (allowedOfficeIds.isEmpty) return >[]; + 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 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 >[]; + return list.sublist(start, end); +} + /// Provider for ticket query parameters. final ticketsQueryProvider = StateProvider( (ref) => const TicketQuery(),