Pagination
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user