Moved long running task to an isolate
This commit is contained in:
parent
1ce38618f6
commit
f7f22c50d2
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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>(
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user