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(...)` —
|
// NOTE: Supabase stream builder does not support `.range(...)` —
|
||||||
// apply pagination and remaining filters client-side after mapping.
|
// apply pagination and remaining filters client-side after mapping.
|
||||||
final baseStream = client
|
final baseStream = client.from('tasks').stream(primaryKey: ['id']);
|
||||||
.from('tasks')
|
|
||||||
.stream(primaryKey: ['id'])
|
return baseStream.asyncMap((rows) async {
|
||||||
.map((rows) => rows.map(Task.fromMap).toList());
|
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 =
|
final allowedTicketIds =
|
||||||
ticketsAsync.valueOrNull?.map((ticket) => ticket.id).toList() ??
|
ticketsAsync.valueOrNull?.map((ticket) => ticket.id).toList() ??
|
||||||
<String>[];
|
<String>[];
|
||||||
final officeIds =
|
final allowedOfficeIds =
|
||||||
assignmentsAsync.valueOrNull
|
assignmentsAsync.valueOrNull
|
||||||
?.where((assignment) => assignment.userId == profile.id)
|
?.where((assignment) => assignment.userId == profile.id)
|
||||||
.map((assignment) => assignment.officeId)
|
.map((assignment) => assignment.officeId)
|
||||||
.toSet()
|
|
||||||
.toList() ??
|
.toList() ??
|
||||||
<String>[];
|
<String>[];
|
||||||
if (allowedTicketIds.isEmpty && officeIds.isEmpty) return <Task>[];
|
|
||||||
final allowedTickets = allowedTicketIds.toSet();
|
final payload = <String, dynamic>{
|
||||||
final allowedOffices = officeIds.toSet();
|
'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
|
list = list
|
||||||
.where(
|
.where(
|
||||||
(t) =>
|
(t) => ((t['task_number'] as String?) ?? '').toLowerCase().contains(
|
||||||
(t.ticketId != null && allowedTickets.contains(t.ticketId)) ||
|
tnLow,
|
||||||
(t.officeId != null && allowedOffices.contains(t.officeId)),
|
),
|
||||||
)
|
)
|
||||||
.toList();
|
.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) {
|
int statusRank(String s) {
|
||||||
switch (s) {
|
switch (s) {
|
||||||
case 'queued':
|
case 'queued':
|
||||||
|
|
@ -217,65 +255,69 @@ final tasksProvider = StreamProvider<List<Task>>((ref) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
int? parseTaskNumber(Task t) {
|
int? parseTaskNumberFromString(String? tn) {
|
||||||
final tn = t.taskNumber;
|
|
||||||
if (tn == null) return null;
|
if (tn == null) return null;
|
||||||
final m = RegExp(r'\d+').firstMatch(tn);
|
final m = RegExp(r'\d+').firstMatch(tn);
|
||||||
if (m == null) return null;
|
if (m == null) return null;
|
||||||
return int.tryParse(m.group(0)!);
|
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) {
|
list.sort((a, b) {
|
||||||
final ra = statusRank(a.status);
|
final ra = statusRank((a['status'] as String?) ?? '');
|
||||||
final rb = statusRank(b.status);
|
final rb = statusRank((b['status'] as String?) ?? '');
|
||||||
final rcmp = ra.compareTo(rb);
|
final rcmp = ra.compareTo(rb);
|
||||||
if (rcmp != 0) return rcmp;
|
if (rcmp != 0) return rcmp;
|
||||||
|
|
||||||
// Same status: apply within-group ordering
|
|
||||||
if (ra == 0) {
|
if (ra == 0) {
|
||||||
// queued: higher priority first, then queue_order asc, then created_at
|
// 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;
|
if (pcmp != 0) return pcmp;
|
||||||
final aOrder = a.queueOrder ?? 0x7fffffff;
|
final aOrder = (a['queue_order'] as int?) ?? 0x7fffffff;
|
||||||
final bOrder = b.queueOrder ?? 0x7fffffff;
|
final bOrder = (b['queue_order'] as int?) ?? 0x7fffffff;
|
||||||
final qcmp = aOrder.compareTo(bOrder);
|
final qcmp = aOrder.compareTo(bOrder);
|
||||||
if (qcmp != 0) return qcmp;
|
if (qcmp != 0) return qcmp;
|
||||||
return a.createdAt.compareTo(b.createdAt);
|
return _parseCreatedAt(a).compareTo(_parseCreatedAt(b));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ra == 1) {
|
if (ra == 1) {
|
||||||
// in_progress: keep older first
|
return _parseCreatedAt(a).compareTo(_parseCreatedAt(b));
|
||||||
return a.createdAt.compareTo(b.createdAt);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ra == 2) {
|
if (ra == 2) {
|
||||||
// completed: prefer numeric task_number DESC when present
|
final an = parseTaskNumberFromString(a['task_number'] as String?);
|
||||||
final an = parseTaskNumber(a);
|
final bn = parseTaskNumberFromString(b['task_number'] as String?);
|
||||||
final bn = parseTaskNumber(b);
|
|
||||||
if (an != null && bn != null) return bn.compareTo(an);
|
if (an != null && bn != null) return bn.compareTo(an);
|
||||||
if (an != null) return -1;
|
if (an != null) return -1;
|
||||||
if (bn != 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['queue_order'] as int?) ?? 0x7fffffff;
|
||||||
final aOrder = a.queueOrder ?? 0x7fffffff;
|
final bOrder = (b['queue_order'] as int?) ?? 0x7fffffff;
|
||||||
final bOrder = b.queueOrder ?? 0x7fffffff;
|
|
||||||
final cmp = aOrder.compareTo(bOrder);
|
final cmp = aOrder.compareTo(bOrder);
|
||||||
if (cmp != 0) return cmp;
|
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;
|
return list;
|
||||||
});
|
}
|
||||||
});
|
|
||||||
|
|
||||||
/// Provider for task query parameters.
|
/// Provider for task query parameters.
|
||||||
final tasksQueryProvider = StateProvider<TaskQuery>((ref) => const TaskQuery());
|
final tasksQueryProvider = StateProvider<TaskQuery>((ref) => const TaskQuery());
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import 'dart:async';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
import '../models/office.dart';
|
import '../models/office.dart';
|
||||||
import '../models/ticket.dart';
|
import '../models/ticket.dart';
|
||||||
|
|
@ -127,57 +128,113 @@ final ticketsProvider = StreamProvider<List<Ticket>>((ref) {
|
||||||
profile.role == 'dispatcher' ||
|
profile.role == 'dispatcher' ||
|
||||||
profile.role == 'it_staff';
|
profile.role == 'it_staff';
|
||||||
|
|
||||||
// Use stream for realtime updates, then apply pagination & search filters
|
// Use stream for realtime updates. Offload expensive client-side
|
||||||
// client-side because `.range(...)` is not supported on the stream builder.
|
// filtering/sorting/pagination to a background isolate via `compute`
|
||||||
final baseStream = client
|
// so UI navigation and builds remain smooth.
|
||||||
.from('tickets')
|
final baseStream = client.from('tickets').stream(primaryKey: ['id']);
|
||||||
.stream(primaryKey: ['id'])
|
|
||||||
.map((rows) => rows.map(Ticket.fromMap).toList());
|
|
||||||
|
|
||||||
return baseStream.map((allTickets) {
|
return baseStream.asyncMap((rows) async {
|
||||||
debugPrint('[ticketsProvider] stream event: ${allTickets.length} rows');
|
// rows is List<dynamic> of maps coming from Supabase
|
||||||
var list = allTickets;
|
final rowsList = (rows as List<dynamic>).cast<Map<String, dynamic>>();
|
||||||
|
|
||||||
if (!isGlobal) {
|
// Prepare lightweight serializable args for background processing
|
||||||
final officeIds =
|
final allowedOfficeIds =
|
||||||
assignmentsAsync.valueOrNull
|
assignmentsAsync.valueOrNull
|
||||||
?.where((assignment) => assignment.userId == profile.id)
|
?.where((assignment) => assignment.userId == profile.id)
|
||||||
.map((assignment) => assignment.officeId)
|
.map((assignment) => assignment.officeId)
|
||||||
.toSet()
|
|
||||||
.toList() ??
|
.toList() ??
|
||||||
<String>[];
|
<String>[];
|
||||||
if (officeIds.isEmpty) return <Ticket>[];
|
|
||||||
final allowedOffices = officeIds.toSet();
|
|
||||||
list = list.where((t) => allowedOffices.contains(t.officeId)).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (query.officeId != null) {
|
final payload = <String, dynamic>{
|
||||||
list = list.where((t) => t.officeId == query.officeId).toList();
|
'rows': rowsList,
|
||||||
}
|
'isGlobal': isGlobal,
|
||||||
if (query.status != null) {
|
'allowedOfficeIds': allowedOfficeIds,
|
||||||
list = list.where((t) => t.status == query.status).toList();
|
'offset': query.offset,
|
||||||
}
|
'limit': query.limit,
|
||||||
if (query.searchQuery.isNotEmpty) {
|
'searchQuery': query.searchQuery,
|
||||||
final q = query.searchQuery.toLowerCase();
|
'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
|
list = list
|
||||||
.where(
|
.where((t) => allowedOfficeIds.contains(t['office_id']))
|
||||||
(t) =>
|
|
||||||
t.subject.toLowerCase().contains(q) ||
|
|
||||||
t.description.toLowerCase().contains(q),
|
|
||||||
)
|
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort: newest first
|
final officeId = payload['officeId'] as String?;
|
||||||
list.sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
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 searchQuery = (payload['searchQuery'] as String?) ?? '';
|
||||||
final start = query.offset;
|
if (searchQuery.isNotEmpty) {
|
||||||
final end = (start + query.limit).clamp(0, list.length);
|
final q = searchQuery.toLowerCase();
|
||||||
if (start >= list.length) return <Ticket>[];
|
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);
|
return list.sublist(start, end);
|
||||||
});
|
}
|
||||||
});
|
|
||||||
|
|
||||||
/// Provider for ticket query parameters.
|
/// Provider for ticket query parameters.
|
||||||
final ticketsQueryProvider = StateProvider<TicketQuery>(
|
final ticketsQueryProvider = StateProvider<TicketQuery>(
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user