From 5f666ed6ea27254d578242d7274d236078dad99a Mon Sep 17 00:00:00 2001 From: Marc Rejohn Castillano Date: Tue, 17 Feb 2026 06:46:52 +0800 Subject: [PATCH] Pagination --- lib/providers/admin_user_provider.dart | 33 ++ lib/providers/tasks_provider.dart | 156 ++++++-- lib/providers/tickets_provider.dart | 152 ++++++-- lib/providers/typing_provider.dart | 102 ++++- lib/screens/admin/offices_screen.dart | 6 + lib/screens/admin/user_management_screen.dart | 6 + .../searchable_multi_select_dropdown.dart | 4 +- lib/screens/tasks/tasks_list_screen.dart | 6 + lib/screens/teams/teams_screen.dart | 11 +- lib/screens/tickets/tickets_list_screen.dart | 7 + lib/widgets/tasq_adaptive_list.dart | 366 ++++++++++++------ test/layout_smoke_test.dart | 76 +++- test/typing_dispose_race_test.dart | 36 ++ 13 files changed, 787 insertions(+), 174 deletions(-) create mode 100644 test/typing_dispose_race_test.dart diff --git a/lib/providers/admin_user_provider.dart b/lib/providers/admin_user_provider.dart index a2b0958d..c7f14254 100644 --- a/lib/providers/admin_user_provider.dart +++ b/lib/providers/admin_user_provider.dart @@ -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((ref) => const AdminUserQuery()); + final adminUserControllerProvider = Provider((ref) { final client = ref.watch(supabaseClientProvider); return AdminUserController(client); diff --git a/lib/providers/tasks_provider.dart b/lib/providers/tasks_provider.dart index 17e29c2d..ae9b1476 100644 --- a/lib/providers/tasks_provider.dart +++ b/lib/providers/tasks_provider.dart @@ -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>((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>((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 earlyAllowedTicketIds = ticketsAsync.valueOrNull?.map((ticket) => ticket.id).toList() ?? []; - final officeIds = + List earlyOfficeIds = assignmentsAsync.valueOrNull ?.where((assignment) => assignment.userId == profile.id) .map((assignment) => assignment.officeId) .toSet() .toList() ?? []; - - if (allowedTicketIds.isEmpty && officeIds.isEmpty) { + if (!isGlobal && earlyAllowedTicketIds.isEmpty && earlyOfficeIds.isEmpty) { return Stream.value(const []); } - 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() ?? + []; + final officeIds = + assignmentsAsync.valueOrNull + ?.where((assignment) => assignment.userId == profile.id) + .map((assignment) => assignment.officeId) + .toSet() + .toList() ?? + []; + if (allowedTicketIds.isEmpty && officeIds.isEmpty) return []; + 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 []; + return list.sublist(start, end); + }); }); +/// Provider for task query parameters. +final tasksQueryProvider = StateProvider((ref) => const TaskQuery()); + final taskAssignmentsProvider = StreamProvider>((ref) { final client = ref.watch(supabaseClientProvider); return client diff --git a/lib/providers/tickets_provider.dart b/lib/providers/tickets_provider.dart index 111067e2..3b8db565 100644 --- a/lib/providers/tickets_provider.dart +++ b/lib/providers/tickets_provider.dart @@ -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>((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( + (ref) => const OfficeQuery(), +); + final officesControllerProvider = Provider((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>((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>((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() ?? - []; - if (officeIds.isEmpty) { - return Stream.value(const []); - } - - 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() ?? + []; + if (officeIds.isEmpty) return []; + 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 []; + return list.sublist(start, end); + }); }); +/// Provider for ticket query parameters. +final ticketsQueryProvider = StateProvider( + (ref) => const TicketQuery(), +); + final ticketMessagesProvider = StreamProvider.family, String>((ref, ticketId) { final client = ref.watch(supabaseClientProvider); diff --git a/lib/providers/typing_provider.dart b/lib/providers/typing_provider.dart index 0d7993fa..66579608 100644 --- a/lib/providers/typing_provider.dart +++ b/lib/providers/typing_provider.dart @@ -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 { RealtimeChannel? _channel; Timer? _typingTimer; final Map _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 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 { }, ); 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 { } 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 { ); } + // 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(); } } diff --git a/lib/screens/admin/offices_screen.dart b/lib/screens/admin/offices_screen.dart index 1826cc3d..bf63335f 100644 --- a/lib/screens/admin/offices_screen.dart +++ b/lib/screens/admin/offices_screen.dart @@ -102,6 +102,12 @@ class _OfficesScreenState extends ConsumerState { ), ); }, + onRequestRefresh: () { + // For server-side pagination, update the query provider + ref.read(officesQueryProvider.notifier).state = + const OfficeQuery(offset: 0, limit: 50); + }, + isLoading: false, ); return Column( diff --git a/lib/screens/admin/user_management_screen.dart b/lib/screens/admin/user_management_screen.dart index d53aba9e..f0da2cec 100644 --- a/lib/screens/admin/user_management_screen.dart +++ b/lib/screens/admin/user_management_screen.dart @@ -239,6 +239,12 @@ class _UserManagementScreenState extends ConsumerState { ), ); }, + onRequestRefresh: () { + // For server-side pagination, update the query provider + ref.read(adminUserQueryProvider.notifier).state = + const AdminUserQuery(offset: 0, limit: 50); + }, + isLoading: false, ); return Padding( diff --git a/lib/screens/searchable_multi_select_dropdown.dart b/lib/screens/searchable_multi_select_dropdown.dart index f80a4bf3..9c156862 100644 --- a/lib/screens/searchable_multi_select_dropdown.dart +++ b/lib/screens/searchable_multi_select_dropdown.dart @@ -3,14 +3,14 @@ import 'package:flutter/material.dart'; /// Searchable multi-select dropdown with chips and 'Select All' option class SearchableMultiSelectDropdown extends StatefulWidget { const SearchableMultiSelectDropdown({ - Key? key, + super.key, required this.label, required this.items, required this.selectedIds, required this.getId, required this.getLabel, required this.onChanged, - }) : super(key: key); + }); final String label; final List items; diff --git a/lib/screens/tasks/tasks_list_screen.dart b/lib/screens/tasks/tasks_list_screen.dart index 7d24d912..cdf6ccb5 100644 --- a/lib/screens/tasks/tasks_list_screen.dart +++ b/lib/screens/tasks/tasks_list_screen.dart @@ -205,6 +205,12 @@ class _TasksListScreenState extends ConsumerState { onRowTap: (task) => context.go('/tasks/${task.id}'), summaryDashboard: summaryDashboard, 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: [ TasQColumn( header: 'Task ID', diff --git a/lib/screens/teams/teams_screen.dart b/lib/screens/teams/teams_screen.dart index bdfde295..8b770275 100644 --- a/lib/screens/teams/teams_screen.dart +++ b/lib/screens/teams/teams_screen.dart @@ -17,7 +17,7 @@ final officesProvider = FutureProvider>((ref) async { }); class TeamsScreen extends ConsumerWidget { - const TeamsScreen({Key? key}) : super(key: key); + const TeamsScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -127,6 +127,7 @@ class TeamsScreen extends ConsumerWidget { }) async { final profiles = ref.read(profilesProvider).valueOrNull ?? []; final offices = await ref.read(officesProvider.future); + if (!context.mounted) return; final itStaff = profiles.where((p) => p.role == 'it_staff').toList(); final nameController = TextEditingController(text: team?.name ?? ''); String? leaderId = team?.leaderId; @@ -204,6 +205,7 @@ class TeamsScreen extends ConsumerWidget { ), ElevatedButton( onPressed: () async { + final navigator = Navigator.of(context); final name = nameController.text.trim(); if (name.isEmpty || leaderId == null) return; final client = Supabase.instance.client; @@ -247,7 +249,7 @@ class TeamsScreen extends ConsumerWidget { } ref.invalidate(teamsProvider); ref.invalidate(teamMembersProvider); - Navigator.of(context).pop(); + navigator.pop(); }, child: Text(isEdit ? 'Save' : 'Add'), ), @@ -260,6 +262,7 @@ class TeamsScreen extends ConsumerWidget { } void _deleteTeam(BuildContext context, WidgetRef ref, String teamId) async { + final navigator = Navigator.of(context); final confirmed = await showDialog( context: context, builder: (context) => AlertDialog( @@ -267,11 +270,11 @@ class TeamsScreen extends ConsumerWidget { content: const Text('Are you sure you want to delete this team?'), actions: [ TextButton( - onPressed: () => Navigator.of(context).pop(false), + onPressed: () => navigator.pop(false), child: const Text('Cancel'), ), ElevatedButton( - onPressed: () => Navigator.of(context).pop(true), + onPressed: () => navigator.pop(true), child: const Text('Delete'), ), ], diff --git a/lib/screens/tickets/tickets_list_screen.dart b/lib/screens/tickets/tickets_list_screen.dart index 795ed72a..d7ab1b42 100644 --- a/lib/screens/tickets/tickets_list_screen.dart +++ b/lib/screens/tickets/tickets_list_screen.dart @@ -168,6 +168,13 @@ class _TicketsListScreenState extends ConsumerState { onRowTap: (ticket) => context.go('/tickets/${ticket.id}'), summaryDashboard: summaryDashboard, 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: [ TasQColumn( header: 'Ticket ID', diff --git a/lib/widgets/tasq_adaptive_list.dart b/lib/widgets/tasq_adaptive_list.dart index 7d77ec0e..e0f14554 100644 --- a/lib/widgets/tasq_adaptive_list.dart +++ b/lib/widgets/tasq_adaptive_list.dart @@ -5,26 +5,43 @@ import 'package:flutter/material.dart'; import '../theme/app_typography.dart'; import 'mono_text.dart'; +/// A column configuration for the [TasQAdaptiveList] desktop table view. class TasQColumn { + /// Creates a column configuration. const TasQColumn({ required this.header, required this.cellBuilder, this.technical = false, }); + /// The column header text. final String header; + + /// Builds the cell content for each row. final Widget Function(BuildContext context, T item) cellBuilder; + + /// If true, applies monospace text style to the cell content. final bool technical; } +/// Builds a mobile tile for [TasQAdaptiveList]. typedef TasQMobileTileBuilder = Widget Function(BuildContext context, T item, List actions); +/// Returns a list of action widgets for a given item. typedef TasQRowActions = List Function(T item); +/// Callback when a row is tapped. typedef TasQRowTap = 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 extends StatelessWidget { + /// Creates an adaptive list. const TasQAdaptiveList({ super.key, required this.items, @@ -32,156 +49,281 @@ class TasQAdaptiveList extends StatelessWidget { required this.mobileTileBuilder, this.rowActions, this.onRowTap, - this.rowsPerPage = 25, + this.rowsPerPage = 50, this.tableHeader, this.filterHeader, this.summaryDashboard, + this.onRequestRefresh, + this.isLoading = false, }); + /// The list of items to display. final List items; + + /// The column configurations for the desktop table view. final List> columns; + + /// Builds the mobile tile for each item. final TasQMobileTileBuilder mobileTileBuilder; + + /// Returns action widgets for each row (e.g., edit/delete buttons). final TasQRowActions? rowActions; + + /// Callback when a row is tapped. final TasQRowTap? onRowTap; + + /// Number of rows per page for desktop view. + /// + /// Per CLAUDE.md: Standard page size is 50 items for Desktop. final int rowsPerPage; + + /// Optional header widget for the desktop table. final Widget? tableHeader; + + /// Optional filter header widget that appears above the list/table. final Widget? filterHeader; + + /// Optional summary dashboard widget (e.g., status counts). 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 Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { final isMobile = constraints.maxWidth < 600; - final hasBoundedHeight = constraints.hasBoundedHeight; if (isMobile) { - final listView = ListView.separated( - padding: const EdgeInsets.only(bottom: 24), - itemCount: items.length, - separatorBuilder: (context, index) => const SizedBox(height: 12), - itemBuilder: (context, index) { - final item = items[index]; - final actions = rowActions?.call(item) ?? const []; - return mobileTileBuilder(context, item, actions); - }, - ); - final shrinkWrappedList = ListView.separated( - padding: const EdgeInsets.only(bottom: 24), - itemCount: items.length, - separatorBuilder: (context, index) => const SizedBox(height: 12), - itemBuilder: (context, index) { - final item = items[index]; - final actions = rowActions?.call(item) ?? const []; - return mobileTileBuilder(context, item, actions); - }, - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - ); - final summarySection = summaryDashboard == null - ? null - : [ - SizedBox(width: double.infinity, child: summaryDashboard!), - const SizedBox(height: 12), - ]; - final filterSection = filterHeader == null - ? null - : [ - ExpansionTile( - title: const Text('Filters'), - children: [ - Padding( - padding: const EdgeInsets.only(bottom: 8), - child: filterHeader!, - ), - ], - ), - const SizedBox(height: 8), - ]; + return _buildMobile(context, constraints); + } + + return _buildDesktop(context, constraints); + }, + ); + } + + 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), + itemCount: items.length + (isLoading ? 1 : 0), + separatorBuilder: (context, index) => const SizedBox(height: 12), + itemBuilder: (context, index) { + if (index >= items.length) { + // Loading indicator for infinite scroll return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - ...?summarySection, - ...?filterSection, - if (hasBoundedHeight) Expanded(child: listView), - if (!hasBoundedHeight) shrinkWrappedList, - ], + padding: const EdgeInsets.only(top: 8), + child: SizedBox( + height: 24, + child: Center(child: CircularProgressIndicator(strokeWidth: 2)), ), ); } - - final dataSource = _TasQTableSource( - context: context, - items: items, - columns: columns, - rowActions: rowActions, + final item = items[index]; + final actions = rowActions?.call(item) ?? const []; + return _MobileTile( + item: item, + actions: actions, + mobileTileBuilder: mobileTileBuilder, onRowTap: onRowTap, ); - final contentWidth = constraints.maxWidth * 0.8; - final tableWidth = math.max( - contentWidth, - (columns.length + (rowActions == null ? 0 : 1)) * 140.0, - ); - final effectiveRowsPerPage = math.min( - rowsPerPage, - math.max(1, items.length), - ); - final tableWidget = SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: SizedBox( - width: tableWidth, - child: PaginatedDataTable( - header: tableHeader, - rowsPerPage: effectiveRowsPerPage, - columnSpacing: 20, - horizontalMargin: 16, - showCheckboxColumn: false, - headingRowColor: WidgetStateProperty.resolveWith( - (states) => Theme.of(context).colorScheme.surfaceContainer, - ), - columns: [ - for (final column in columns) - DataColumn(label: Text(column.header)), - if (rowActions != null) - const DataColumn(label: Text('Actions')), - ], - source: dataSource, - ), - ), - ); + }, + ); - final summarySection = summaryDashboard == null - ? null - : [ - SizedBox(width: contentWidth, child: summaryDashboard!), - const SizedBox(height: 12), - ]; - final filterSection = filterHeader == null - ? null - : [filterHeader!, const SizedBox(height: 12)]; - - return SingleChildScrollView( - primary: hasBoundedHeight, - child: Center( + // 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( - width: contentWidth, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [...?summarySection, ...?filterSection, tableWidget], - ), + height: 24, + child: Center(child: CircularProgressIndicator(strokeWidth: 2)), ), - ), + ); + } + final item = items[index]; + final actions = rowActions?.call(item) ?? const []; + return _MobileTile( + item: item, + actions: actions, + mobileTileBuilder: mobileTileBuilder, + onRowTap: onRowTap, ); }, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + ); + + final summarySection = summaryDashboard == null + ? null + : [ + SizedBox(width: double.infinity, child: summaryDashboard!), + const SizedBox(height: 12), + ]; + final filterSection = filterHeader == null + ? null + : [ + ExpansionTile( + title: const Text('Filters'), + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: filterHeader!, + ), + ], + ), + const SizedBox(height: 8), + ]; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ...?summarySection, + ...?filterSection, + if (hasBoundedHeight) + Expanded(child: _buildInfiniteScrollListener(listView)) + else + _buildInfiniteScrollListener(shrinkWrappedList), + ], + ), + ); + } + + Widget _buildInfiniteScrollListener(Widget listView) { + if (onRequestRefresh == null) { + return listView; + } + return NotificationListener( + 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( + context: context, + items: items, + columns: columns, + rowActions: rowActions, + onRowTap: onRowTap, + ); + + final contentWidth = constraints.maxWidth * 0.8; + final tableWidth = math.max( + contentWidth, + (columns.length + (rowActions == null ? 0 : 1)) * 140.0, + ); + final effectiveRowsPerPage = math.min( + rowsPerPage, + math.max(1, items.length), + ); + + final tableWidget = SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: SizedBox( + width: tableWidth, + child: PaginatedDataTable( + header: tableHeader, + rowsPerPage: effectiveRowsPerPage, + columnSpacing: 20, + horizontalMargin: 16, + showCheckboxColumn: false, + headingRowColor: WidgetStateProperty.resolveWith( + (states) => Theme.of(context).colorScheme.surfaceContainer, + ), + columns: [ + for (final column in columns) + DataColumn(label: Text(column.header)), + if (rowActions != null) const DataColumn(label: Text('Actions')), + ], + source: dataSource, + ), + ), + ); + + final summarySection = summaryDashboard == null + ? null + : [ + SizedBox(width: contentWidth, child: summaryDashboard!), + const SizedBox(height: 12), + ]; + final filterSection = filterHeader == null + ? null + : [filterHeader!, const SizedBox(height: 12)]; + + return SingleChildScrollView( + primary: true, + child: Center( + child: SizedBox( + width: contentWidth, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [...?summarySection, ...?filterSection, tableWidget], + ), + ), + ), ); } } +/// Mobile tile wrapper that applies Material 2 style elevation. +class _MobileTile extends StatelessWidget { + const _MobileTile({ + required this.item, + required this.actions, + required this.mobileTileBuilder, + required this.onRowTap, + }); + + final T item; + final List actions; + final TasQMobileTileBuilder mobileTileBuilder; + final TasQRowTap? 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 extends DataTableSource { + /// Creates a table source for [TasQAdaptiveList]. _TasQTableSource({ required this.context, required this.items, diff --git a/test/layout_smoke_test.dart b/test/layout_smoke_test.dart index 3a0e0ff6..ef64fdca 100644 --- a/test/layout_smoke_test.dart +++ b/test/layout_smoke_test.dart @@ -11,14 +11,39 @@ import 'package:tasq/models/ticket.dart'; import 'package:tasq/models/user_office.dart'; import 'package:tasq/providers/notifications_provider.dart'; import 'package:tasq/providers/profile_provider.dart'; + import 'package:tasq/providers/tasks_provider.dart'; import 'package:tasq/providers/tickets_provider.dart'; import 'package:tasq/providers/typing_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/user_management_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/ticket_detail_screen.dart'; + +// Test double for NotificationsController so widget tests don't initialize +// a real Supabase client. +class FakeNotificationsController implements NotificationsController { + @override + Future createMentionNotifications({ + required List userIds, + required String actorId, + required int messageId, + String? ticketId, + String? taskId, + }) async {} + + @override + Future markRead(String id) async {} + + @override + Future markReadForTicket(String ticketId) async {} + + @override + Future markReadForTask(String taskId) async {} +} SupabaseClient _fakeSupabaseClient() { return SupabaseClient('http://localhost', 'test-key'); @@ -69,8 +94,10 @@ void main() { List baseOverrides() { return [ + supabaseClientProvider.overrideWithValue(_fakeSupabaseClient()), currentProfileProvider.overrideWith((ref) => Stream.value(admin)), profilesProvider.overrideWith((ref) => Stream.value([admin, tech])), + officesProvider.overrideWith((ref) => Stream.value([office])), notificationsProvider.overrideWith((ref) => Stream.value([notification])), ticketsProvider.overrideWith((ref) => Stream.value([ticket])), @@ -81,6 +108,9 @@ void main() { ), ticketMessagesAllProvider.overrideWith((ref) => const Stream.empty()), isAdminProvider.overrideWith((ref) => true), + notificationsControllerProvider.overrideWithValue( + FakeNotificationsController(), + ), typingIndicatorProvider.overrideWithProvider( AutoDisposeStateNotifierProvider.family< TypingIndicatorController, @@ -114,8 +144,8 @@ void main() { const TicketsListScreen(), overrides: baseOverrides(), ); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 16)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 16)); expect(tester.takeException(), isNull); }); @@ -126,8 +156,8 @@ void main() { const TasksListScreen(), overrides: baseOverrides(), ); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 16)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 16)); expect(tester.takeException(), isNull); }); @@ -140,8 +170,8 @@ void main() { const OfficesScreen(), overrides: baseOverrides(), ); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 16)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 16)); expect(tester.takeException(), isNull); }); @@ -154,8 +184,38 @@ void main() { const UserManagementScreen(), overrides: userManagementOverrides(), ); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 16)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 16)); + 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); }); }); diff --git a/test/typing_dispose_race_test.dart b/test/typing_dispose_race_test.dart new file mode 100644 index 00000000..79a3294f --- /dev/null +++ b/test/typing_dispose_race_test.dart @@ -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); + }, + ); +}