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
+130 -22
View File
@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:flutter/material.dart';
import '../models/office.dart';
import '../models/ticket.dart';
@@ -27,15 +28,93 @@ final officesOnceProvider = FutureProvider<List<Office>>((ref) async {
.toList();
});
/// Office query parameters for server-side pagination.
class OfficeQuery {
/// Creates office query parameters.
const OfficeQuery({this.offset = 0, this.limit = 50, this.searchQuery = ''});
/// Offset for pagination.
final int offset;
/// Number of items per page (default: 50).
final int limit;
/// Full text search query.
final String searchQuery;
OfficeQuery copyWith({int? offset, int? limit, String? searchQuery}) {
return OfficeQuery(
offset: offset ?? this.offset,
limit: limit ?? this.limit,
searchQuery: searchQuery ?? this.searchQuery,
);
}
}
final officesQueryProvider = StateProvider<OfficeQuery>(
(ref) => const OfficeQuery(),
);
final officesControllerProvider = Provider<OfficesController>((ref) {
final client = ref.watch(supabaseClientProvider);
return OfficesController(client);
});
/// Ticket query parameters for server-side pagination and filtering.
class TicketQuery {
/// Creates ticket query parameters.
const TicketQuery({
this.offset = 0,
this.limit = 50,
this.searchQuery = '',
this.officeId,
this.status,
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 date range.
/// Filter by date range.
final DateTimeRange? dateRange;
TicketQuery copyWith({
int? offset,
int? limit,
String? searchQuery,
String? officeId,
String? status,
DateTimeRange? dateRange,
}) {
return TicketQuery(
offset: offset ?? this.offset,
limit: limit ?? this.limit,
searchQuery: searchQuery ?? this.searchQuery,
officeId: officeId ?? this.officeId,
status: status ?? this.status,
dateRange: dateRange ?? this.dateRange,
);
}
}
final ticketsProvider = StreamProvider<List<Ticket>>((ref) {
final client = ref.watch(supabaseClientProvider);
final profileAsync = ref.watch(currentProfileProvider);
final assignmentsAsync = ref.watch(userOfficesProvider);
final query = ref.watch(ticketsQueryProvider);
final profile = profileAsync.valueOrNull;
if (profile == null) {
@@ -47,33 +126,62 @@ final ticketsProvider = StreamProvider<List<Ticket>>((ref) {
profile.role == 'dispatcher' ||
profile.role == 'it_staff';
if (isGlobal) {
return client
.from('tickets')
.stream(primaryKey: ['id'])
.order('created_at', ascending: false)
.map((rows) => rows.map(Ticket.fromMap).toList());
}
final officeIds =
assignmentsAsync.valueOrNull
?.where((assignment) => assignment.userId == profile.id)
.map((assignment) => assignment.officeId)
.toSet()
.toList() ??
<String>[];
if (officeIds.isEmpty) {
return Stream.value(const <Ticket>[]);
}
return client
// 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'])
.inFilter('office_id', officeIds)
.order('created_at', ascending: false)
.map((rows) => rows.map(Ticket.fromMap).toList());
return baseStream.map((allTickets) {
var list = allTickets;
if (!isGlobal) {
final officeIds =
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();
list = list
.where(
(t) =>
t.subject.toLowerCase().contains(q) ||
t.description.toLowerCase().contains(q),
)
.toList();
}
// Sort: newest first
list.sort((a, b) => b.createdAt.compareTo(a.createdAt));
// Pagination
final start = query.offset;
final end = (start + query.limit).clamp(0, list.length);
if (start >= list.length) return <Ticket>[];
return list.sublist(start, end);
});
});
/// Provider for ticket query parameters.
final ticketsQueryProvider = StateProvider<TicketQuery>(
(ref) => const TicketQuery(),
);
final ticketMessagesProvider =
StreamProvider.family<List<TicketMessage>, String>((ref, ticketId) {
final client = ref.watch(supabaseClientProvider);