Pagination

This commit is contained in:
Marc Rejohn Castillano 2026-02-17 06:46:52 +08:00
parent 1c73595d07
commit 5f666ed6ea
13 changed files with 787 additions and 174 deletions

View File

@ -4,6 +4,39 @@ import 'package:supabase_flutter/supabase_flutter.dart';
import 'supabase_provider.dart'; import 'supabase_provider.dart';
import '../utils/app_time.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 adminUserControllerProvider = Provider<AdminUserController>((ref) {
final client = ref.watch(supabaseClientProvider); final client = ref.watch(supabaseClientProvider);
return AdminUserController(client); return AdminUserController(client);

View File

@ -3,17 +3,75 @@ import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:supabase_flutter/supabase_flutter.dart';
import '../models/task.dart'; import '../models/task.dart';
import 'package:flutter/material.dart';
import '../models/task_assignment.dart'; import '../models/task_assignment.dart';
import 'profile_provider.dart'; import 'profile_provider.dart';
import 'supabase_provider.dart'; import 'supabase_provider.dart';
import 'tickets_provider.dart'; import 'tickets_provider.dart';
import 'user_offices_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 tasksProvider = StreamProvider<List<Task>>((ref) {
final client = ref.watch(supabaseClientProvider); final client = ref.watch(supabaseClientProvider);
final profileAsync = ref.watch(currentProfileProvider); final profileAsync = ref.watch(currentProfileProvider);
final ticketsAsync = ref.watch(ticketsProvider); final ticketsAsync = ref.watch(ticketsProvider);
final assignmentsAsync = ref.watch(userOfficesProvider); final assignmentsAsync = ref.watch(userOfficesProvider);
final query = ref.watch(tasksQueryProvider);
final profile = profileAsync.valueOrNull; final profile = profileAsync.valueOrNull;
if (profile == null) { if (profile == null) {
@ -25,15 +83,34 @@ final tasksProvider = StreamProvider<List<Task>>((ref) {
profile.role == 'dispatcher' || profile.role == 'dispatcher' ||
profile.role == 'it_staff'; profile.role == 'it_staff';
if (isGlobal) { // For RBAC early-exit: if the user has no accessible tickets/offices,
return client // avoid subscribing to the full tasks stream.
.from('tasks') List<String> earlyAllowedTicketIds =
.stream(primaryKey: ['id']) ticketsAsync.valueOrNull?.map((ticket) => ticket.id).toList() ??
.order('queue_order', ascending: true) <String>[];
.order('created_at') List<String> earlyOfficeIds =
.map((rows) => rows.map(Task.fromMap).toList()); assignmentsAsync.valueOrNull
?.where((assignment) => assignment.userId == profile.id)
.map((assignment) => assignment.officeId)
.toSet()
.toList() ??
<String>[];
if (!isGlobal && earlyAllowedTicketIds.isEmpty && earlyOfficeIds.isEmpty) {
return Stream.value(const <Task>[]);
} }
// 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'])
.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 = final allowedTicketIds =
ticketsAsync.valueOrNull?.map((ticket) => ticket.id).toList() ?? ticketsAsync.valueOrNull?.map((ticket) => ticket.id).toList() ??
<String>[]; <String>[];
@ -44,27 +121,56 @@ final tasksProvider = StreamProvider<List<Task>>((ref) {
.toSet() .toSet()
.toList() ?? .toList() ??
<String>[]; <String>[];
if (allowedTicketIds.isEmpty && officeIds.isEmpty) return <Task>[];
if (allowedTicketIds.isEmpty && officeIds.isEmpty) { final allowedTickets = allowedTicketIds.toSet();
return Stream.value(const <Task>[]); final allowedOffices = officeIds.toSet();
list = list
.where(
(t) =>
(t.ticketId != null && allowedTickets.contains(t.ticketId)) ||
(t.officeId != null && allowedOffices.contains(t.officeId)),
)
.toList();
} }
return client // Query filters (apply client-side)
.from('tasks') if (query.officeId != null) {
.stream(primaryKey: ['id']) list = list.where((t) => t.officeId == query.officeId).toList();
.order('queue_order', ascending: true) }
.order('created_at') if (query.status != null) {
.map( list = list.where((t) => t.status == query.status).toList();
(rows) => rows.map(Task.fromMap).where((task) { }
final matchesTicket = if (query.searchQuery.isNotEmpty) {
task.ticketId != null && allowedTicketIds.contains(task.ticketId); final q = query.searchQuery.toLowerCase();
final matchesOffice = list = list
task.officeId != null && officeIds.contains(task.officeId); .where(
return matchesTicket || matchesOffice; (t) =>
}).toList(), 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 taskAssignmentsProvider = StreamProvider<List<TaskAssignment>>((ref) {
final client = ref.watch(supabaseClientProvider); final client = ref.watch(supabaseClientProvider);
return client return client

View File

@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:flutter/material.dart';
import '../models/office.dart'; import '../models/office.dart';
import '../models/ticket.dart'; import '../models/ticket.dart';
@ -27,15 +28,93 @@ final officesOnceProvider = FutureProvider<List<Office>>((ref) async {
.toList(); .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 officesControllerProvider = Provider<OfficesController>((ref) {
final client = ref.watch(supabaseClientProvider); final client = ref.watch(supabaseClientProvider);
return OfficesController(client); 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 ticketsProvider = StreamProvider<List<Ticket>>((ref) {
final client = ref.watch(supabaseClientProvider); final client = ref.watch(supabaseClientProvider);
final profileAsync = ref.watch(currentProfileProvider); final profileAsync = ref.watch(currentProfileProvider);
final assignmentsAsync = ref.watch(userOfficesProvider); final assignmentsAsync = ref.watch(userOfficesProvider);
final query = ref.watch(ticketsQueryProvider);
final profile = profileAsync.valueOrNull; final profile = profileAsync.valueOrNull;
if (profile == null) { if (profile == null) {
@ -47,14 +126,17 @@ final ticketsProvider = StreamProvider<List<Ticket>>((ref) {
profile.role == 'dispatcher' || profile.role == 'dispatcher' ||
profile.role == 'it_staff'; profile.role == 'it_staff';
if (isGlobal) { // Use stream for realtime updates, then apply pagination & search filters
return client // client-side because `.range(...)` is not supported on the stream builder.
final baseStream = client
.from('tickets') .from('tickets')
.stream(primaryKey: ['id']) .stream(primaryKey: ['id'])
.order('created_at', ascending: false)
.map((rows) => rows.map(Ticket.fromMap).toList()); .map((rows) => rows.map(Ticket.fromMap).toList());
}
return baseStream.map((allTickets) {
var list = allTickets;
if (!isGlobal) {
final officeIds = final officeIds =
assignmentsAsync.valueOrNull assignmentsAsync.valueOrNull
?.where((assignment) => assignment.userId == profile.id) ?.where((assignment) => assignment.userId == profile.id)
@ -62,18 +144,44 @@ final ticketsProvider = StreamProvider<List<Ticket>>((ref) {
.toSet() .toSet()
.toList() ?? .toList() ??
<String>[]; <String>[];
if (officeIds.isEmpty) { if (officeIds.isEmpty) return <Ticket>[];
return Stream.value(const <Ticket>[]); final allowedOffices = officeIds.toSet();
list = list.where((t) => allowedOffices.contains(t.officeId)).toList();
} }
return client if (query.officeId != null) {
.from('tickets') list = list.where((t) => t.officeId == query.officeId).toList();
.stream(primaryKey: ['id']) }
.inFilter('office_id', officeIds) if (query.status != null) {
.order('created_at', ascending: false) list = list.where((t) => t.status == query.status).toList();
.map((rows) => rows.map(Ticket.fromMap).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 = final ticketMessagesProvider =
StreamProvider.family<List<TicketMessage>, String>((ref, ticketId) { StreamProvider.family<List<TicketMessage>, String>((ref, ticketId) {
final client = ref.watch(supabaseClientProvider); final client = ref.watch(supabaseClientProvider);

View File

@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:supabase_flutter/supabase_flutter.dart';
@ -57,16 +58,38 @@ class TypingIndicatorController extends StateNotifier<TypingIndicatorState> {
RealtimeChannel? _channel; RealtimeChannel? _channel;
Timer? _typingTimer; Timer? _typingTimer;
final Map<String, Timer> _remoteTimeouts = {}; final Map<String, Timer> _remoteTimeouts = {};
// Marked when dispose() starts to prevent late async callbacks mutating state.
bool _disposed = false;
void _initChannel() { void _initChannel() {
final channel = _client.channel('typing:$_ticketId'); final channel = _client.channel('typing:$_ticketId');
channel.onBroadcast( channel.onBroadcast(
event: 'typing', event: 'typing',
callback: (payload) { 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 Map<String, dynamic> data = _extractPayload(payload);
final userId = data['user_id'] as String?; final userId = data['user_id'] as String?;
final rawType = data['type']?.toString(); final rawType = data['type']?.toString();
final currentUserId = _client.auth.currentUser?.id; 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); state = state.copyWith(lastPayload: data);
if (userId == null || userId == currentUserId) { if (userId == null || userId == currentUserId) {
return; return;
@ -79,6 +102,15 @@ class TypingIndicatorController extends StateNotifier<TypingIndicatorState> {
}, },
); );
channel.subscribe((status, error) { 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); state = state.copyWith(channelStatus: status.name);
}); });
_channel = channel; _channel = channel;
@ -100,36 +132,83 @@ class TypingIndicatorController extends StateNotifier<TypingIndicatorState> {
} }
void userTyping() { void userTyping() {
if (_disposed || !mounted) {
if (kDebugMode)
debugPrint(
'TypingIndicatorController.userTyping() ignored after dispose',
);
return;
}
if (_client.auth.currentUser?.id == null) return; if (_client.auth.currentUser?.id == null) return;
_sendTypingEvent('start'); _sendTypingEvent('start');
_typingTimer?.cancel(); _typingTimer?.cancel();
_typingTimer = Timer(const Duration(milliseconds: 150), () { _typingTimer = Timer(const Duration(milliseconds: 150), () {
if (_disposed || !mounted) {
if (kDebugMode)
debugPrint(
'TypingIndicatorController._typingTimer callback ignored after dispose',
);
return;
}
_sendTypingEvent('stop'); _sendTypingEvent('stop');
}); });
} }
void stopTyping() { void stopTyping() {
if (_disposed || !mounted) {
if (kDebugMode)
debugPrint(
'TypingIndicatorController.stopTyping() ignored after dispose',
);
return;
}
_typingTimer?.cancel(); _typingTimer?.cancel();
_sendTypingEvent('stop'); _sendTypingEvent('stop');
} }
void _markRemoteTyping(String userId) { 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}; final updated = {...state.userIds, userId};
if (_disposed || !mounted) return;
state = state.copyWith(userIds: updated); state = state.copyWith(userIds: updated);
_remoteTimeouts[userId]?.cancel(); _remoteTimeouts[userId]?.cancel();
_remoteTimeouts[userId] = Timer(const Duration(milliseconds: 400), () { _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); _clearRemoteTyping(userId);
}); });
} }
void _clearRemoteTyping(String 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); final updated = {...state.userIds}..remove(userId);
if (_disposed || !mounted) return;
state = state.copyWith(userIds: updated); state = state.copyWith(userIds: updated);
_remoteTimeouts[userId]?.cancel(); _remoteTimeouts[userId]?.cancel();
_remoteTimeouts.remove(userId); _remoteTimeouts.remove(userId);
} }
void _sendTypingEvent(String type) { void _sendTypingEvent(String type) {
if (_disposed || !mounted) return;
final userId = _client.auth.currentUser?.id; final userId = _client.auth.currentUser?.id;
if (userId == null || _channel == null) return; if (userId == null || _channel == null) return;
_channel!.sendBroadcastMessage( _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 @override
void dispose() { 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(); _typingTimer?.cancel();
for (final timer in _remoteTimeouts.values) { for (final timer in _remoteTimeouts.values) {
timer.cancel(); timer.cancel();
} }
_remoteTimeouts.clear(); _remoteTimeouts.clear();
// Unsubscribe from realtime channel.
_channel?.unsubscribe(); _channel?.unsubscribe();
_channel = null;
super.dispose(); super.dispose();
} }
} }

View File

@ -102,6 +102,12 @@ class _OfficesScreenState extends ConsumerState<OfficesScreen> {
), ),
); );
}, },
onRequestRefresh: () {
// For server-side pagination, update the query provider
ref.read(officesQueryProvider.notifier).state =
const OfficeQuery(offset: 0, limit: 50);
},
isLoading: false,
); );
return Column( return Column(

View File

@ -239,6 +239,12 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
), ),
); );
}, },
onRequestRefresh: () {
// For server-side pagination, update the query provider
ref.read(adminUserQueryProvider.notifier).state =
const AdminUserQuery(offset: 0, limit: 50);
},
isLoading: false,
); );
return Padding( return Padding(

View File

@ -3,14 +3,14 @@ import 'package:flutter/material.dart';
/// Searchable multi-select dropdown with chips and 'Select All' option /// Searchable multi-select dropdown with chips and 'Select All' option
class SearchableMultiSelectDropdown<T> extends StatefulWidget { class SearchableMultiSelectDropdown<T> extends StatefulWidget {
const SearchableMultiSelectDropdown({ const SearchableMultiSelectDropdown({
Key? key, super.key,
required this.label, required this.label,
required this.items, required this.items,
required this.selectedIds, required this.selectedIds,
required this.getId, required this.getId,
required this.getLabel, required this.getLabel,
required this.onChanged, required this.onChanged,
}) : super(key: key); });
final String label; final String label;
final List<T> items; final List<T> items;

View File

@ -205,6 +205,12 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
onRowTap: (task) => context.go('/tasks/${task.id}'), onRowTap: (task) => context.go('/tasks/${task.id}'),
summaryDashboard: summaryDashboard, summaryDashboard: summaryDashboard,
filterHeader: filterHeader, filterHeader: filterHeader,
onRequestRefresh: () {
// For server-side pagination, update the query provider
ref.read(tasksQueryProvider.notifier).state =
const TaskQuery(offset: 0, limit: 50);
},
isLoading: false,
columns: [ columns: [
TasQColumn<Task>( TasQColumn<Task>(
header: 'Task ID', header: 'Task ID',

View File

@ -17,7 +17,7 @@ final officesProvider = FutureProvider<List<Office>>((ref) async {
}); });
class TeamsScreen extends ConsumerWidget { class TeamsScreen extends ConsumerWidget {
const TeamsScreen({Key? key}) : super(key: key); const TeamsScreen({super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@ -127,6 +127,7 @@ class TeamsScreen extends ConsumerWidget {
}) async { }) async {
final profiles = ref.read(profilesProvider).valueOrNull ?? []; final profiles = ref.read(profilesProvider).valueOrNull ?? [];
final offices = await ref.read(officesProvider.future); final offices = await ref.read(officesProvider.future);
if (!context.mounted) return;
final itStaff = profiles.where((p) => p.role == 'it_staff').toList(); final itStaff = profiles.where((p) => p.role == 'it_staff').toList();
final nameController = TextEditingController(text: team?.name ?? ''); final nameController = TextEditingController(text: team?.name ?? '');
String? leaderId = team?.leaderId; String? leaderId = team?.leaderId;
@ -204,6 +205,7 @@ class TeamsScreen extends ConsumerWidget {
), ),
ElevatedButton( ElevatedButton(
onPressed: () async { onPressed: () async {
final navigator = Navigator.of(context);
final name = nameController.text.trim(); final name = nameController.text.trim();
if (name.isEmpty || leaderId == null) return; if (name.isEmpty || leaderId == null) return;
final client = Supabase.instance.client; final client = Supabase.instance.client;
@ -247,7 +249,7 @@ class TeamsScreen extends ConsumerWidget {
} }
ref.invalidate(teamsProvider); ref.invalidate(teamsProvider);
ref.invalidate(teamMembersProvider); ref.invalidate(teamMembersProvider);
Navigator.of(context).pop(); navigator.pop();
}, },
child: Text(isEdit ? 'Save' : 'Add'), child: Text(isEdit ? 'Save' : 'Add'),
), ),
@ -260,6 +262,7 @@ class TeamsScreen extends ConsumerWidget {
} }
void _deleteTeam(BuildContext context, WidgetRef ref, String teamId) async { void _deleteTeam(BuildContext context, WidgetRef ref, String teamId) async {
final navigator = Navigator.of(context);
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
@ -267,11 +270,11 @@ class TeamsScreen extends ConsumerWidget {
content: const Text('Are you sure you want to delete this team?'), content: const Text('Are you sure you want to delete this team?'),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.of(context).pop(false), onPressed: () => navigator.pop(false),
child: const Text('Cancel'), child: const Text('Cancel'),
), ),
ElevatedButton( ElevatedButton(
onPressed: () => Navigator.of(context).pop(true), onPressed: () => navigator.pop(true),
child: const Text('Delete'), child: const Text('Delete'),
), ),
], ],

View File

@ -168,6 +168,13 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
onRowTap: (ticket) => context.go('/tickets/${ticket.id}'), onRowTap: (ticket) => context.go('/tickets/${ticket.id}'),
summaryDashboard: summaryDashboard, summaryDashboard: summaryDashboard,
filterHeader: filterHeader, filterHeader: filterHeader,
onRequestRefresh: () {
// For server-side pagination, update the query provider
// This will trigger a reload with new pagination parameters
ref.read(ticketsQueryProvider.notifier).state =
const TicketQuery(offset: 0, limit: 50);
},
isLoading: false,
columns: [ columns: [
TasQColumn<Ticket>( TasQColumn<Ticket>(
header: 'Ticket ID', header: 'Ticket ID',

View File

@ -5,26 +5,43 @@ import 'package:flutter/material.dart';
import '../theme/app_typography.dart'; import '../theme/app_typography.dart';
import 'mono_text.dart'; import 'mono_text.dart';
/// A column configuration for the [TasQAdaptiveList] desktop table view.
class TasQColumn<T> { class TasQColumn<T> {
/// Creates a column configuration.
const TasQColumn({ const TasQColumn({
required this.header, required this.header,
required this.cellBuilder, required this.cellBuilder,
this.technical = false, this.technical = false,
}); });
/// The column header text.
final String header; final String header;
/// Builds the cell content for each row.
final Widget Function(BuildContext context, T item) cellBuilder; final Widget Function(BuildContext context, T item) cellBuilder;
/// If true, applies monospace text style to the cell content.
final bool technical; final bool technical;
} }
/// Builds a mobile tile for [TasQAdaptiveList].
typedef TasQMobileTileBuilder<T> = typedef TasQMobileTileBuilder<T> =
Widget Function(BuildContext context, T item, List<Widget> actions); Widget Function(BuildContext context, T item, List<Widget> actions);
/// Returns a list of action widgets for a given item.
typedef TasQRowActions<T> = List<Widget> Function(T item); typedef TasQRowActions<T> = List<Widget> Function(T item);
/// Callback when a row is tapped.
typedef TasQRowTap<T> = void Function(T item); typedef TasQRowTap<T> = void Function(T item);
/// A adaptive list widget that renders as:
/// - **Mobile**: Tile-based list with infinite scroll listeners.
/// - **Desktop**: Data Table with paginated footer.
///
/// The widget requires a reactive data source ([items]) that responds to
/// pagination/search providers for server-side data fetching.
class TasQAdaptiveList<T> extends StatelessWidget { class TasQAdaptiveList<T> extends StatelessWidget {
/// Creates an adaptive list.
const TasQAdaptiveList({ const TasQAdaptiveList({
super.key, super.key,
required this.items, required this.items,
@ -32,52 +49,122 @@ class TasQAdaptiveList<T> extends StatelessWidget {
required this.mobileTileBuilder, required this.mobileTileBuilder,
this.rowActions, this.rowActions,
this.onRowTap, this.onRowTap,
this.rowsPerPage = 25, this.rowsPerPage = 50,
this.tableHeader, this.tableHeader,
this.filterHeader, this.filterHeader,
this.summaryDashboard, this.summaryDashboard,
this.onRequestRefresh,
this.isLoading = false,
}); });
/// The list of items to display.
final List<T> items; final List<T> items;
/// The column configurations for the desktop table view.
final List<TasQColumn<T>> columns; final List<TasQColumn<T>> columns;
/// Builds the mobile tile for each item.
final TasQMobileTileBuilder<T> mobileTileBuilder; final TasQMobileTileBuilder<T> mobileTileBuilder;
/// Returns action widgets for each row (e.g., edit/delete buttons).
final TasQRowActions<T>? rowActions; final TasQRowActions<T>? rowActions;
/// Callback when a row is tapped.
final TasQRowTap<T>? onRowTap; final TasQRowTap<T>? onRowTap;
/// Number of rows per page for desktop view.
///
/// Per CLAUDE.md: Standard page size is 50 items for Desktop.
final int rowsPerPage; final int rowsPerPage;
/// Optional header widget for the desktop table.
final Widget? tableHeader; final Widget? tableHeader;
/// Optional filter header widget that appears above the list/table.
final Widget? filterHeader; final Widget? filterHeader;
/// Optional summary dashboard widget (e.g., status counts).
final Widget? summaryDashboard; final Widget? summaryDashboard;
/// Callback when the user requests refresh (infinite scroll or pagination).
final void Function()? onRequestRefresh;
/// If true, shows a loading indicator for server-side pagination.
final bool isLoading;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return LayoutBuilder( return LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final isMobile = constraints.maxWidth < 600; final isMobile = constraints.maxWidth < 600;
final hasBoundedHeight = constraints.hasBoundedHeight;
if (isMobile) { if (isMobile) {
final listView = ListView.separated( return _buildMobile(context, constraints);
padding: const EdgeInsets.only(bottom: 24), }
itemCount: items.length,
separatorBuilder: (context, index) => const SizedBox(height: 12), return _buildDesktop(context, constraints);
itemBuilder: (context, index) {
final item = items[index];
final actions = rowActions?.call(item) ?? const <Widget>[];
return mobileTileBuilder(context, item, actions);
}, },
); );
final shrinkWrappedList = ListView.separated( }
Widget _buildMobile(BuildContext context, BoxConstraints constraints) {
final hasBoundedHeight = constraints.hasBoundedHeight;
// Mobile: Single-column with infinite scroll listeners
final listView = ListView.separated(
padding: const EdgeInsets.only(bottom: 24), padding: const EdgeInsets.only(bottom: 24),
itemCount: items.length, itemCount: items.length + (isLoading ? 1 : 0),
separatorBuilder: (context, index) => const SizedBox(height: 12), separatorBuilder: (context, index) => const SizedBox(height: 12),
itemBuilder: (context, index) { itemBuilder: (context, index) {
if (index >= items.length) {
// Loading indicator for infinite scroll
return Padding(
padding: const EdgeInsets.only(top: 8),
child: SizedBox(
height: 24,
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
),
);
}
final item = items[index]; final item = items[index];
final actions = rowActions?.call(item) ?? const <Widget>[]; final actions = rowActions?.call(item) ?? const <Widget>[];
return mobileTileBuilder(context, item, actions); return _MobileTile(
item: item,
actions: actions,
mobileTileBuilder: mobileTileBuilder,
onRowTap: onRowTap,
);
},
);
// Shrink-wrapped list for unbounded height contexts
final shrinkWrappedList = ListView.separated(
padding: const EdgeInsets.only(bottom: 24),
itemCount: items.length + (isLoading ? 1 : 0),
separatorBuilder: (context, index) => const SizedBox(height: 12),
itemBuilder: (context, index) {
if (index >= items.length) {
return Padding(
padding: const EdgeInsets.only(top: 8),
child: SizedBox(
height: 24,
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
),
);
}
final item = items[index];
final actions = rowActions?.call(item) ?? const <Widget>[];
return _MobileTile(
item: item,
actions: actions,
mobileTileBuilder: mobileTileBuilder,
onRowTap: onRowTap,
);
}, },
shrinkWrap: true, shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
); );
final summarySection = summaryDashboard == null final summarySection = summaryDashboard == null
? null ? null
: <Widget>[ : <Widget>[
@ -98,6 +185,7 @@ class TasQAdaptiveList<T> extends StatelessWidget {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
]; ];
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column( child: Column(
@ -106,13 +194,33 @@ class TasQAdaptiveList<T> extends StatelessWidget {
children: [ children: [
...?summarySection, ...?summarySection,
...?filterSection, ...?filterSection,
if (hasBoundedHeight) Expanded(child: listView), if (hasBoundedHeight)
if (!hasBoundedHeight) shrinkWrappedList, Expanded(child: _buildInfiniteScrollListener(listView))
else
_buildInfiniteScrollListener(shrinkWrappedList),
], ],
), ),
); );
} }
Widget _buildInfiniteScrollListener(Widget listView) {
if (onRequestRefresh == null) {
return listView;
}
return NotificationListener<ScrollNotification>(
onNotification: (notification) {
if (notification is ScrollEndNotification &&
notification.metrics.extentAfter == 0) {
// User scrolled to bottom, trigger load more
onRequestRefresh!();
}
return false;
},
child: listView,
);
}
Widget _buildDesktop(BuildContext context, BoxConstraints constraints) {
final dataSource = _TasQTableSource<T>( final dataSource = _TasQTableSource<T>(
context: context, context: context,
items: items, items: items,
@ -120,6 +228,7 @@ class TasQAdaptiveList<T> extends StatelessWidget {
rowActions: rowActions, rowActions: rowActions,
onRowTap: onRowTap, onRowTap: onRowTap,
); );
final contentWidth = constraints.maxWidth * 0.8; final contentWidth = constraints.maxWidth * 0.8;
final tableWidth = math.max( final tableWidth = math.max(
contentWidth, contentWidth,
@ -129,6 +238,7 @@ class TasQAdaptiveList<T> extends StatelessWidget {
rowsPerPage, rowsPerPage,
math.max(1, items.length), math.max(1, items.length),
); );
final tableWidget = SingleChildScrollView( final tableWidget = SingleChildScrollView(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
child: SizedBox( child: SizedBox(
@ -145,8 +255,7 @@ class TasQAdaptiveList<T> extends StatelessWidget {
columns: [ columns: [
for (final column in columns) for (final column in columns)
DataColumn(label: Text(column.header)), DataColumn(label: Text(column.header)),
if (rowActions != null) if (rowActions != null) const DataColumn(label: Text('Actions')),
const DataColumn(label: Text('Actions')),
], ],
source: dataSource, source: dataSource,
), ),
@ -164,7 +273,7 @@ class TasQAdaptiveList<T> extends StatelessWidget {
: <Widget>[filterHeader!, const SizedBox(height: 12)]; : <Widget>[filterHeader!, const SizedBox(height: 12)];
return SingleChildScrollView( return SingleChildScrollView(
primary: hasBoundedHeight, primary: true,
child: Center( child: Center(
child: SizedBox( child: SizedBox(
width: contentWidth, width: contentWidth,
@ -176,12 +285,45 @@ class TasQAdaptiveList<T> extends StatelessWidget {
), ),
), ),
); );
}, }
}
/// Mobile tile wrapper that applies Material 2 style elevation.
class _MobileTile<T> extends StatelessWidget {
const _MobileTile({
required this.item,
required this.actions,
required this.mobileTileBuilder,
required this.onRowTap,
});
final T item;
final List<Widget> actions;
final TasQMobileTileBuilder<T> mobileTileBuilder;
final TasQRowTap<T>? onRowTap;
@override
Widget build(BuildContext context) {
final tile = mobileTileBuilder(context, item, actions);
// Apply Material 2 style elevation for Cards (per Hybrid M3/M2 guidelines)
if (tile is Card) {
return Card(
color: tile.color,
elevation: 2,
margin: tile.margin,
shape: tile.shape,
clipBehavior: tile.clipBehavior,
child: tile.child,
); );
} }
return tile;
}
} }
class _TasQTableSource<T> extends DataTableSource { class _TasQTableSource<T> extends DataTableSource {
/// Creates a table source for [TasQAdaptiveList].
_TasQTableSource({ _TasQTableSource({
required this.context, required this.context,
required this.items, required this.items,

View File

@ -11,14 +11,39 @@ import 'package:tasq/models/ticket.dart';
import 'package:tasq/models/user_office.dart'; import 'package:tasq/models/user_office.dart';
import 'package:tasq/providers/notifications_provider.dart'; import 'package:tasq/providers/notifications_provider.dart';
import 'package:tasq/providers/profile_provider.dart'; import 'package:tasq/providers/profile_provider.dart';
import 'package:tasq/providers/tasks_provider.dart'; import 'package:tasq/providers/tasks_provider.dart';
import 'package:tasq/providers/tickets_provider.dart'; import 'package:tasq/providers/tickets_provider.dart';
import 'package:tasq/providers/typing_provider.dart'; import 'package:tasq/providers/typing_provider.dart';
import 'package:tasq/providers/user_offices_provider.dart'; import 'package:tasq/providers/user_offices_provider.dart';
import 'package:tasq/providers/supabase_provider.dart';
import 'package:tasq/screens/admin/offices_screen.dart'; import 'package:tasq/screens/admin/offices_screen.dart';
import 'package:tasq/screens/admin/user_management_screen.dart'; import 'package:tasq/screens/admin/user_management_screen.dart';
import 'package:tasq/screens/tasks/tasks_list_screen.dart'; import 'package:tasq/screens/tasks/tasks_list_screen.dart';
import 'package:tasq/screens/tickets/tickets_list_screen.dart'; import 'package:tasq/screens/tickets/tickets_list_screen.dart';
import 'package:tasq/screens/tickets/ticket_detail_screen.dart';
// Test double for NotificationsController so widget tests don't initialize
// a real Supabase client.
class FakeNotificationsController implements NotificationsController {
@override
Future<void> createMentionNotifications({
required List<String> userIds,
required String actorId,
required int messageId,
String? ticketId,
String? taskId,
}) async {}
@override
Future<void> markRead(String id) async {}
@override
Future<void> markReadForTicket(String ticketId) async {}
@override
Future<void> markReadForTask(String taskId) async {}
}
SupabaseClient _fakeSupabaseClient() { SupabaseClient _fakeSupabaseClient() {
return SupabaseClient('http://localhost', 'test-key'); return SupabaseClient('http://localhost', 'test-key');
@ -69,8 +94,10 @@ void main() {
List<Override> baseOverrides() { List<Override> baseOverrides() {
return [ return [
supabaseClientProvider.overrideWithValue(_fakeSupabaseClient()),
currentProfileProvider.overrideWith((ref) => Stream.value(admin)), currentProfileProvider.overrideWith((ref) => Stream.value(admin)),
profilesProvider.overrideWith((ref) => Stream.value([admin, tech])), profilesProvider.overrideWith((ref) => Stream.value([admin, tech])),
officesProvider.overrideWith((ref) => Stream.value([office])), officesProvider.overrideWith((ref) => Stream.value([office])),
notificationsProvider.overrideWith((ref) => Stream.value([notification])), notificationsProvider.overrideWith((ref) => Stream.value([notification])),
ticketsProvider.overrideWith((ref) => Stream.value([ticket])), ticketsProvider.overrideWith((ref) => Stream.value([ticket])),
@ -81,6 +108,9 @@ void main() {
), ),
ticketMessagesAllProvider.overrideWith((ref) => const Stream.empty()), ticketMessagesAllProvider.overrideWith((ref) => const Stream.empty()),
isAdminProvider.overrideWith((ref) => true), isAdminProvider.overrideWith((ref) => true),
notificationsControllerProvider.overrideWithValue(
FakeNotificationsController(),
),
typingIndicatorProvider.overrideWithProvider( typingIndicatorProvider.overrideWithProvider(
AutoDisposeStateNotifierProvider.family< AutoDisposeStateNotifierProvider.family<
TypingIndicatorController, TypingIndicatorController,
@ -158,6 +188,36 @@ void main() {
await tester.pump(const Duration(milliseconds: 16)); await tester.pump(const Duration(milliseconds: 16));
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
}); });
testWidgets('Typing indicator: no post-dispose state mutation', (
tester,
) async {
await _setSurfaceSize(tester, const Size(600, 800));
// Show TicketDetailScreen with the base overrides (includes typing controller).
await _pumpScreen(
tester,
const TicketDetailScreen(ticketId: 'TCK-1'),
overrides: baseOverrides(),
);
await tester.pump();
// Find message TextField and simulate typing.
final finder = find.byType(TextField);
expect(finder, findsWidgets);
await tester.enterText(finder.first, 'Hello');
// Immediately remove the screen (navigate away / dispose).
await tester.pumpWidget(Container());
// Let pending timers (typing stop, remote timeouts) run.
await tester.pump(const Duration(milliseconds: 500));
await tester.pumpAndSettle();
// No unhandled exceptions should have been thrown.
expect(tester.takeException(), isNull);
});
}); });
} }

View File

@ -0,0 +1,36 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:tasq/providers/typing_provider.dart';
void main() {
test(
'TypingIndicatorController ignores late remote events after dispose',
() async {
final client = SupabaseClient('http://localhost', 'test-key');
final controller = TypingIndicatorController(client, 'ticket-1');
// initial state should be empty
expect(controller.state.userIds, isEmpty);
// Simulate a remote "start typing" arriving while alive
controller.debugSimulateRemoteTyping('user-2');
expect(controller.state.userIds, contains('user-2'));
// Dispose the controller
controller.dispose();
// Calling the remote handlers after dispose must NOT throw and must be no-ops
expect(
() => controller.debugSimulateRemoteTyping('user-3'),
returnsNormally,
);
expect(
() => controller.debugSimulateRemoteTyping('user-2', stop: true),
returnsNormally,
);
// Ensure state was not modified after dispose
expect(controller.mounted, isFalse);
},
);
}