From eaabc0114c534453ceffc7fca43f3639b7ae32fc Mon Sep 17 00:00:00 2001 From: Marc Rejohn Castillano Date: Wed, 25 Feb 2026 18:25:00 +0800 Subject: [PATCH] Proper pagination --- lib/providers/tasks_provider.dart | 73 +++++++++++++++++-- lib/screens/admin/offices_screen.dart | 5 ++ lib/screens/admin/user_management_screen.dart | 5 ++ lib/screens/tasks/tasks_list_screen.dart | 48 +++++++----- lib/screens/tickets/tickets_list_screen.dart | 5 ++ lib/widgets/tasq_adaptive_list.dart | 47 ++++++++++-- 6 files changed, 153 insertions(+), 30 deletions(-) diff --git a/lib/providers/tasks_provider.dart b/lib/providers/tasks_provider.dart index 29cced88..64cb4fec 100644 --- a/lib/providers/tasks_provider.dart +++ b/lib/providers/tasks_provider.dart @@ -192,8 +192,66 @@ final tasksProvider = StreamProvider>((ref) { .toList(); } - // Sort: queue_order ASC, then created_at ASC + // Sort by status groups then within-group ordering: + // 1. queued – order by priority (desc), then queue_order (asc), then created_at + // 2. in_progress – preserve recent order (created_at asc) + // 3. completed – order by numeric task_number when available (asc) + // 4. other statuses – fallback to queue_order then created_at + final statusRank = (String s) { + switch (s) { + case 'queued': + return 0; + case 'in_progress': + return 1; + case 'completed': + return 2; + default: + return 3; + } + }; + + int? _parseTaskNumber(Task t) { + final tn = t.taskNumber; + if (tn == null) return null; + final m = RegExp(r'\d+').firstMatch(tn); + if (m == null) return null; + return int.tryParse(m.group(0)!); + } + list.sort((a, b) { + final ra = statusRank(a.status); + final rb = statusRank(b.status); + final rcmp = ra.compareTo(rb); + if (rcmp != 0) return rcmp; + + // Same status: apply within-group ordering + if (ra == 0) { + // queued: higher priority first, then queue_order asc, then created_at + final pcmp = b.priority.compareTo(a.priority); + if (pcmp != 0) return pcmp; + final aOrder = a.queueOrder ?? 0x7fffffff; + final bOrder = b.queueOrder ?? 0x7fffffff; + final qcmp = aOrder.compareTo(bOrder); + if (qcmp != 0) return qcmp; + return a.createdAt.compareTo(b.createdAt); + } + + if (ra == 1) { + // in_progress: keep older first + return a.createdAt.compareTo(b.createdAt); + } + + if (ra == 2) { + // completed: prefer numeric task_number DESC when present + final an = _parseTaskNumber(a); + final bn = _parseTaskNumber(b); + if (an != null && bn != null) return bn.compareTo(an); + if (an != null) return -1; + if (bn != null) return 1; + return b.createdAt.compareTo(a.createdAt); + } + + // fallback: queue_order then created_at final aOrder = a.queueOrder ?? 0x7fffffff; final bOrder = b.queueOrder ?? 0x7fffffff; final cmp = aOrder.compareTo(bOrder); @@ -201,11 +259,14 @@ final tasksProvider = StreamProvider>((ref) { 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); + // Return the full filtered & sorted list to allow the UI layer to + // perform pagination (desktop PaginatedDataTable expects the full + // row count so it can render pagination controls reliably). The + // Supabase stream currently delivers all rows and the provider + // applies filtering/sorting; leaving pagination to the UI avoids + // off-by-one issues where a full page of results would hide the + // presence of a next page. + return list; }); }); diff --git a/lib/screens/admin/offices_screen.dart b/lib/screens/admin/offices_screen.dart index 574c4898..ed1111cd 100644 --- a/lib/screens/admin/offices_screen.dart +++ b/lib/screens/admin/offices_screen.dart @@ -109,6 +109,11 @@ class _OfficesScreenState extends ConsumerState { ref.read(officesQueryProvider.notifier).state = const OfficeQuery(offset: 0, limit: 50); }, + onPageChanged: (firstRow) { + ref + .read(officesQueryProvider.notifier) + .update((q) => q.copyWith(offset: firstRow)); + }, isLoading: false, ); diff --git a/lib/screens/admin/user_management_screen.dart b/lib/screens/admin/user_management_screen.dart index d23759e6..605c3259 100644 --- a/lib/screens/admin/user_management_screen.dart +++ b/lib/screens/admin/user_management_screen.dart @@ -251,6 +251,11 @@ class _UserManagementScreenState extends ConsumerState { limit: 50, ); }, + onPageChanged: (firstRow) { + ref + .read(adminUserQueryProvider.notifier) + .update((q) => q.copyWith(offset: firstRow)); + }, isLoading: false, ); diff --git a/lib/screens/tasks/tasks_list_screen.dart b/lib/screens/tasks/tasks_list_screen.dart index e3592c74..410871dc 100644 --- a/lib/screens/tasks/tasks_list_screen.dart +++ b/lib/screens/tasks/tasks_list_screen.dart @@ -43,19 +43,28 @@ class TasksListScreen extends ConsumerStatefulWidget { ConsumerState createState() => _TasksListScreenState(); } -class _TasksListScreenState extends ConsumerState { +class _TasksListScreenState extends ConsumerState + with SingleTickerProviderStateMixin { final TextEditingController _subjectController = TextEditingController(); String? _selectedOfficeId; String? _selectedStatus; String? _selectedAssigneeId; DateTimeRange? _selectedDateRange; + late final TabController _tabController; @override void dispose() { _subjectController.dispose(); + _tabController.dispose(); super.dispose(); } + @override + void initState() { + super.initState(); + _tabController = TabController(length: 2, vsync: this); + } + bool get _hasTaskFilters { return _subjectController.text.trim().isNotEmpty || _selectedOfficeId != null || @@ -67,6 +76,7 @@ class _TasksListScreenState extends ConsumerState { @override Widget build(BuildContext context) { final tasksAsync = ref.watch(tasksProvider); + final taskQuery = ref.watch(tasksQueryProvider); final ticketsAsync = ref.watch(ticketsProvider); final officesAsync = ref.watch(officesProvider); final profileAsync = ref.watch(currentProfileProvider); @@ -255,6 +265,7 @@ class _TasksListScreenState extends ConsumerState { ref.read(tasksQueryProvider.notifier).state = const TaskQuery(offset: 0, limit: 50); }, + onPageChanged: null, isLoading: false, columns: [ TasQColumn( @@ -433,26 +444,25 @@ class _TasksListScreenState extends ConsumerState { ), ), Expanded( - child: DefaultTabController( - length: 2, - child: Column( - children: [ - const TabBar( - tabs: [ - Tab(text: 'My Tasks'), - Tab(text: 'All Tasks'), + child: Column( + children: [ + TabBar( + controller: _tabController, + tabs: const [ + Tab(text: 'My Tasks'), + Tab(text: 'All Tasks'), + ], + ), + Expanded( + child: TabBarView( + controller: _tabController, + children: [ + makeList(myTasks), + makeList(filteredTasks), ], ), - Expanded( - child: TabBarView( - children: [ - makeList(myTasks), - makeList(filteredTasks), - ], - ), - ), - ], - ), + ), + ], ), ), ], diff --git a/lib/screens/tickets/tickets_list_screen.dart b/lib/screens/tickets/tickets_list_screen.dart index 68866c97..c6d0d3b4 100644 --- a/lib/screens/tickets/tickets_list_screen.dart +++ b/lib/screens/tickets/tickets_list_screen.dart @@ -181,6 +181,11 @@ class _TicketsListScreenState extends ConsumerState { ref.read(ticketsQueryProvider.notifier).state = const TicketQuery(offset: 0, limit: 50); }, + onPageChanged: (firstRow) { + ref + .read(ticketsQueryProvider.notifier) + .update((q) => q.copyWith(offset: firstRow)); + }, isLoading: false, columns: [ TasQColumn( diff --git a/lib/widgets/tasq_adaptive_list.dart b/lib/widgets/tasq_adaptive_list.dart index 957186e4..0dba733d 100644 --- a/lib/widgets/tasq_adaptive_list.dart +++ b/lib/widgets/tasq_adaptive_list.dart @@ -51,10 +51,13 @@ class TasQAdaptiveList extends StatelessWidget { this.rowActions, this.onRowTap, this.rowsPerPage = 50, + this.totalCount, + this.pageOffset = 0, this.tableHeader, this.filterHeader, this.summaryDashboard, this.onRequestRefresh, + this.onPageChanged, this.isLoading = false, }); @@ -78,6 +81,15 @@ class TasQAdaptiveList extends StatelessWidget { /// Per CLAUDE.md: Standard page size is 50 items for Desktop. final int rowsPerPage; + /// Optional total number of rows in the full result set. When non-null + /// and [items] contains only the current page, this value will be used + /// as the `rowCount` for the table so pagination controls render correctly. + final int? totalCount; + + /// Offset of the first item in [items] relative to the full result set. + /// Used when [totalCount] is provided and [items] represents a page. + final int pageOffset; + /// Optional header widget for the desktop table. final Widget? tableHeader; @@ -87,9 +99,21 @@ class TasQAdaptiveList extends StatelessWidget { /// Optional summary dashboard widget (e.g., status counts). final Widget? summaryDashboard; - /// Callback when the user requests refresh (infinite scroll or pagination). + /// Callback when the user requests refresh (infinite scroll or pagination. + /// + /// * **Mobile** – invoked when the user scrolls to the end of the list. + /// * **Desktop** – *not* triggered by page changes (see [onPageChanged]). final void Function()? onRequestRefresh; + /// Desktop-only callback invoked when the paginated table changes page. + /// + /// The integer parameter is the first row index for the newly visible + /// page, which corresponds directly to the `offset` value used by our + /// server-side pagination providers. Consumers should update the + /// associated query provider (e.g. `tasksQueryProvider`) so that the + /// underlying stream/future is refreshed with the new range. + final void Function(int firstRowIndex)? onPageChanged; + /// If true, shows a loading indicator for server-side pagination. final bool isLoading; @@ -228,6 +252,8 @@ class TasQAdaptiveList extends StatelessWidget { columns: columns, rowActions: rowActions, onRowTap: onRowTap, + offset: pageOffset, + totalCount: totalCount, ); // Use progressively smaller fractions of the viewport on larger screens @@ -248,7 +274,7 @@ class TasQAdaptiveList extends StatelessWidget { ); final effectiveRowsPerPage = math.min( rowsPerPage, - math.max(1, items.length), + math.max(1, totalCount ?? items.length), ); // wrap horizontal scroll with a visible scrollbar on desktop. the @@ -278,6 +304,7 @@ class TasQAdaptiveList extends StatelessWidget { if (rowActions != null) const DataColumn(label: Text('Actions')), ], source: dataSource, + onPageChanged: onPageChanged, ), ), ), @@ -360,6 +387,8 @@ class _TasQTableSource extends DataTableSource { required this.columns, required this.rowActions, required this.onRowTap, + this.offset = 0, + this.totalCount, }); final BuildContext context; @@ -367,11 +396,19 @@ class _TasQTableSource extends DataTableSource { final List> columns; final TasQRowActions? rowActions; final TasQRowTap? onRowTap; + final int offset; + final int? totalCount; @override DataRow? getRow(int index) { - if (index >= items.length) return null; - final item = items[index]; + // Map the global table index to the local items page using the + // provided offset. If items contains the full dataset, offset will be + // zero and this reduces to a direct index. If items contains only the + // current page, index may be outside the local range — return null in + // that case so the table can render blanks for non-loaded rows. + final localIndex = index - offset; + if (localIndex < 0 || localIndex >= items.length) return null; + final item = items[localIndex]; final cells = []; for (final column in columns) { @@ -396,7 +433,7 @@ class _TasQTableSource extends DataTableSource { bool get isRowCountApproximate => false; @override - int get rowCount => items.length; + int get rowCount => totalCount ?? items.length; @override int get selectedRowCount => 0;