Pagination

This commit is contained in:
2026-02-17 06:46:52 +08:00
parent 1c73595d07
commit 5f666ed6ea
13 changed files with 787 additions and 174 deletions
+131 -25
View File
@@ -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