Pagination
This commit is contained in:
@@ -3,17 +3,75 @@ import 'dart:async';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
import '../models/task.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/task_assignment.dart';
|
||||
import 'profile_provider.dart';
|
||||
import 'supabase_provider.dart';
|
||||
import 'tickets_provider.dart';
|
||||
import 'user_offices_provider.dart';
|
||||
|
||||
/// Task query parameters for server-side pagination and filtering.
|
||||
class TaskQuery {
|
||||
/// Creates task query parameters.
|
||||
const TaskQuery({
|
||||
this.offset = 0,
|
||||
this.limit = 50,
|
||||
this.searchQuery = '',
|
||||
this.officeId,
|
||||
this.status,
|
||||
this.assigneeId,
|
||||
this.dateRange,
|
||||
});
|
||||
|
||||
/// Offset for pagination.
|
||||
final int offset;
|
||||
|
||||
/// Number of items per page (default: 50).
|
||||
final int limit;
|
||||
|
||||
/// Full text search query.
|
||||
final String searchQuery;
|
||||
|
||||
/// Filter by office ID.
|
||||
final String? officeId;
|
||||
|
||||
/// Filter by status.
|
||||
final String? status;
|
||||
|
||||
/// Filter by assignee ID.
|
||||
final String? assigneeId;
|
||||
|
||||
/// Filter by date range.
|
||||
/// Filter by date range.
|
||||
final DateTimeRange? dateRange;
|
||||
|
||||
TaskQuery copyWith({
|
||||
int? offset,
|
||||
int? limit,
|
||||
String? searchQuery,
|
||||
String? officeId,
|
||||
String? status,
|
||||
String? assigneeId,
|
||||
DateTimeRange? dateRange,
|
||||
}) {
|
||||
return TaskQuery(
|
||||
offset: offset ?? this.offset,
|
||||
limit: limit ?? this.limit,
|
||||
searchQuery: searchQuery ?? this.searchQuery,
|
||||
officeId: officeId ?? this.officeId,
|
||||
status: status ?? this.status,
|
||||
assigneeId: assigneeId ?? this.assigneeId,
|
||||
dateRange: dateRange ?? this.dateRange,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final tasksProvider = StreamProvider<List<Task>>((ref) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
final profileAsync = ref.watch(currentProfileProvider);
|
||||
final ticketsAsync = ref.watch(ticketsProvider);
|
||||
final assignmentsAsync = ref.watch(userOfficesProvider);
|
||||
final query = ref.watch(tasksQueryProvider);
|
||||
|
||||
final profile = profileAsync.valueOrNull;
|
||||
if (profile == null) {
|
||||
@@ -25,46 +83,94 @@ final tasksProvider = StreamProvider<List<Task>>((ref) {
|
||||
profile.role == 'dispatcher' ||
|
||||
profile.role == 'it_staff';
|
||||
|
||||
if (isGlobal) {
|
||||
return client
|
||||
.from('tasks')
|
||||
.stream(primaryKey: ['id'])
|
||||
.order('queue_order', ascending: true)
|
||||
.order('created_at')
|
||||
.map((rows) => rows.map(Task.fromMap).toList());
|
||||
}
|
||||
|
||||
final allowedTicketIds =
|
||||
// For RBAC early-exit: if the user has no accessible tickets/offices,
|
||||
// avoid subscribing to the full tasks stream.
|
||||
List<String> earlyAllowedTicketIds =
|
||||
ticketsAsync.valueOrNull?.map((ticket) => ticket.id).toList() ??
|
||||
<String>[];
|
||||
final officeIds =
|
||||
List<String> earlyOfficeIds =
|
||||
assignmentsAsync.valueOrNull
|
||||
?.where((assignment) => assignment.userId == profile.id)
|
||||
.map((assignment) => assignment.officeId)
|
||||
.toSet()
|
||||
.toList() ??
|
||||
<String>[];
|
||||
|
||||
if (allowedTicketIds.isEmpty && officeIds.isEmpty) {
|
||||
if (!isGlobal && earlyAllowedTicketIds.isEmpty && earlyOfficeIds.isEmpty) {
|
||||
return Stream.value(const <Task>[]);
|
||||
}
|
||||
|
||||
return client
|
||||
// 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'])
|
||||
.order('queue_order', ascending: true)
|
||||
.order('created_at')
|
||||
.map(
|
||||
(rows) => rows.map(Task.fromMap).where((task) {
|
||||
final matchesTicket =
|
||||
task.ticketId != null && allowedTicketIds.contains(task.ticketId);
|
||||
final matchesOffice =
|
||||
task.officeId != null && officeIds.contains(task.officeId);
|
||||
return matchesTicket || matchesOffice;
|
||||
}).toList(),
|
||||
);
|
||||
.map((rows) => rows.map(Task.fromMap).toList());
|
||||
|
||||
return baseStream.map((allTasks) {
|
||||
// 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 =
|
||||
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();
|
||||
list = list
|
||||
.where(
|
||||
(t) =>
|
||||
(t.ticketId != null && allowedTickets.contains(t.ticketId)) ||
|
||||
(t.officeId != null && allowedOffices.contains(t.officeId)),
|
||||
)
|
||||
.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),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
// Sort: queue_order ASC, then created_at ASC
|
||||
list.sort((a, b) {
|
||||
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);
|
||||
});
|
||||
|
||||
// Pagination (server-side semantics emulated client-side)
|
||||
final start = query.offset;
|
||||
final end = (start + query.limit).clamp(0, list.length);
|
||||
if (start >= list.length) return <Task>[];
|
||||
return list.sublist(start, end);
|
||||
});
|
||||
});
|
||||
|
||||
/// Provider for task query parameters.
|
||||
final tasksQueryProvider = StateProvider<TaskQuery>((ref) => const TaskQuery());
|
||||
|
||||
final taskAssignmentsProvider = StreamProvider<List<TaskAssignment>>((ref) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
return client
|
||||
|
||||
Reference in New Issue
Block a user