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
+33
View File
@@ -4,6 +4,39 @@ import 'package:supabase_flutter/supabase_flutter.dart';
import 'supabase_provider.dart';
import '../utils/app_time.dart';
/// Admin user query parameters for server-side pagination.
class AdminUserQuery {
/// Creates admin user query parameters.
const AdminUserQuery({
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;
AdminUserQuery copyWith({
int? offset,
int? limit,
String? searchQuery,
}) {
return AdminUserQuery(
offset: offset ?? this.offset,
limit: limit ?? this.limit,
searchQuery: searchQuery ?? this.searchQuery,
);
}
}
final adminUserQueryProvider = StateProvider<AdminUserQuery>((ref) => const AdminUserQuery());
final adminUserControllerProvider = Provider<AdminUserController>((ref) {
final client = ref.watch(supabaseClientProvider);
return AdminUserController(client);
+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
+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);
+101 -1
View File
@@ -1,5 +1,6 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
@@ -57,16 +58,38 @@ class TypingIndicatorController extends StateNotifier<TypingIndicatorState> {
RealtimeChannel? _channel;
Timer? _typingTimer;
final Map<String, Timer> _remoteTimeouts = {};
// Marked when dispose() starts to prevent late async callbacks mutating state.
bool _disposed = false;
void _initChannel() {
final channel = _client.channel('typing:$_ticketId');
channel.onBroadcast(
event: 'typing',
callback: (payload) {
// Prevent any work if we're already disposing. Log stack for diagnostics.
if (_disposed || !mounted) {
if (kDebugMode) {
debugPrint(
'TypingIndicatorController: onBroadcast skipped (disposed|unmounted)',
);
debugPrint(StackTrace.current.toString());
}
return;
}
final Map<String, dynamic> data = _extractPayload(payload);
final userId = data['user_id'] as String?;
final rawType = data['type']?.toString();
final currentUserId = _client.auth.currentUser?.id;
if (_disposed || !mounted) {
if (kDebugMode) {
debugPrint(
'TypingIndicatorController: payload received but controller disposed/unmounted',
);
debugPrint(StackTrace.current.toString());
}
return;
}
state = state.copyWith(lastPayload: data);
if (userId == null || userId == currentUserId) {
return;
@@ -79,6 +102,15 @@ class TypingIndicatorController extends StateNotifier<TypingIndicatorState> {
},
);
channel.subscribe((status, error) {
if (_disposed || !mounted) {
if (kDebugMode) {
debugPrint(
'TypingIndicatorController: subscribe callback skipped (disposed|unmounted)',
);
debugPrint(StackTrace.current.toString());
}
return;
}
state = state.copyWith(channelStatus: status.name);
});
_channel = channel;
@@ -100,36 +132,83 @@ class TypingIndicatorController extends StateNotifier<TypingIndicatorState> {
}
void userTyping() {
if (_disposed || !mounted) {
if (kDebugMode)
debugPrint(
'TypingIndicatorController.userTyping() ignored after dispose',
);
return;
}
if (_client.auth.currentUser?.id == null) return;
_sendTypingEvent('start');
_typingTimer?.cancel();
_typingTimer = Timer(const Duration(milliseconds: 150), () {
if (_disposed || !mounted) {
if (kDebugMode)
debugPrint(
'TypingIndicatorController._typingTimer callback ignored after dispose',
);
return;
}
_sendTypingEvent('stop');
});
}
void stopTyping() {
if (_disposed || !mounted) {
if (kDebugMode)
debugPrint(
'TypingIndicatorController.stopTyping() ignored after dispose',
);
return;
}
_typingTimer?.cancel();
_sendTypingEvent('stop');
}
void _markRemoteTyping(String userId) {
if (_disposed || !mounted) {
if (kDebugMode) {
debugPrint(
'TypingIndicatorController._markRemoteTyping ignored after dispose for user: $userId',
);
debugPrint(StackTrace.current.toString());
}
return;
}
final updated = {...state.userIds, userId};
if (_disposed || !mounted) return;
state = state.copyWith(userIds: updated);
_remoteTimeouts[userId]?.cancel();
_remoteTimeouts[userId] = Timer(const Duration(milliseconds: 400), () {
if (_disposed || !mounted) {
if (kDebugMode)
debugPrint(
'TypingIndicatorController.remote timeout callback ignored after dispose for user: $userId',
);
return;
}
_clearRemoteTyping(userId);
});
}
void _clearRemoteTyping(String userId) {
if (_disposed || !mounted) {
if (kDebugMode)
debugPrint(
'TypingIndicatorController._clearRemoteTyping ignored after dispose for user: $userId',
);
return;
}
final updated = {...state.userIds}..remove(userId);
if (_disposed || !mounted) return;
state = state.copyWith(userIds: updated);
_remoteTimeouts[userId]?.cancel();
_remoteTimeouts.remove(userId);
}
void _sendTypingEvent(String type) {
if (_disposed || !mounted) return;
final userId = _client.auth.currentUser?.id;
if (userId == null || _channel == null) return;
_channel!.sendBroadcastMessage(
@@ -138,15 +217,36 @@ class TypingIndicatorController extends StateNotifier<TypingIndicatorState> {
);
}
// Exposed for tests only: simulate a remote typing broadcast.
@visibleForTesting
void debugSimulateRemoteTyping(String userId, {bool stop = false}) {
if (_disposed || !mounted) return;
final data = {'user_id': userId, 'type': stop ? 'stop' : 'start'};
state = state.copyWith(lastPayload: data);
if (stop) {
_clearRemoteTyping(userId);
} else {
_markRemoteTyping(userId);
}
}
@override
void dispose() {
stopTyping();
// Mark disposed first so any late async callbacks will no-op.
_disposed = true;
// Cancel local timers and remote timeouts; do NOT send network events during
// dispose (prevents broadcasts from re-entering callbacks after disposal).
_typingTimer?.cancel();
for (final timer in _remoteTimeouts.values) {
timer.cancel();
}
_remoteTimeouts.clear();
// Unsubscribe from realtime channel.
_channel?.unsubscribe();
_channel = null;
super.dispose();
}
}