Proper pagination

This commit is contained in:
Marc Rejohn Castillano 2026-02-25 18:25:00 +08:00
parent 8e8269d12b
commit eaabc0114c
6 changed files with 153 additions and 30 deletions

View File

@ -192,8 +192,66 @@ final tasksProvider = StreamProvider<List<Task>>((ref) {
.toList(); .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) { 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 aOrder = a.queueOrder ?? 0x7fffffff;
final bOrder = b.queueOrder ?? 0x7fffffff; final bOrder = b.queueOrder ?? 0x7fffffff;
final cmp = aOrder.compareTo(bOrder); final cmp = aOrder.compareTo(bOrder);
@ -201,11 +259,14 @@ final tasksProvider = StreamProvider<List<Task>>((ref) {
return a.createdAt.compareTo(b.createdAt); return a.createdAt.compareTo(b.createdAt);
}); });
// Pagination (server-side semantics emulated client-side) // Return the full filtered & sorted list to allow the UI layer to
final start = query.offset; // perform pagination (desktop PaginatedDataTable expects the full
final end = (start + query.limit).clamp(0, list.length); // row count so it can render pagination controls reliably). The
if (start >= list.length) return <Task>[]; // Supabase stream currently delivers all rows and the provider
return list.sublist(start, end); // 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;
}); });
}); });

View File

@ -109,6 +109,11 @@ class _OfficesScreenState extends ConsumerState<OfficesScreen> {
ref.read(officesQueryProvider.notifier).state = ref.read(officesQueryProvider.notifier).state =
const OfficeQuery(offset: 0, limit: 50); const OfficeQuery(offset: 0, limit: 50);
}, },
onPageChanged: (firstRow) {
ref
.read(officesQueryProvider.notifier)
.update((q) => q.copyWith(offset: firstRow));
},
isLoading: false, isLoading: false,
); );

View File

@ -251,6 +251,11 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
limit: 50, limit: 50,
); );
}, },
onPageChanged: (firstRow) {
ref
.read(adminUserQueryProvider.notifier)
.update((q) => q.copyWith(offset: firstRow));
},
isLoading: false, isLoading: false,
); );

View File

@ -43,19 +43,28 @@ class TasksListScreen extends ConsumerStatefulWidget {
ConsumerState<TasksListScreen> createState() => _TasksListScreenState(); ConsumerState<TasksListScreen> createState() => _TasksListScreenState();
} }
class _TasksListScreenState extends ConsumerState<TasksListScreen> { class _TasksListScreenState extends ConsumerState<TasksListScreen>
with SingleTickerProviderStateMixin {
final TextEditingController _subjectController = TextEditingController(); final TextEditingController _subjectController = TextEditingController();
String? _selectedOfficeId; String? _selectedOfficeId;
String? _selectedStatus; String? _selectedStatus;
String? _selectedAssigneeId; String? _selectedAssigneeId;
DateTimeRange? _selectedDateRange; DateTimeRange? _selectedDateRange;
late final TabController _tabController;
@override @override
void dispose() { void dispose() {
_subjectController.dispose(); _subjectController.dispose();
_tabController.dispose();
super.dispose(); super.dispose();
} }
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
}
bool get _hasTaskFilters { bool get _hasTaskFilters {
return _subjectController.text.trim().isNotEmpty || return _subjectController.text.trim().isNotEmpty ||
_selectedOfficeId != null || _selectedOfficeId != null ||
@ -67,6 +76,7 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final tasksAsync = ref.watch(tasksProvider); final tasksAsync = ref.watch(tasksProvider);
final taskQuery = ref.watch(tasksQueryProvider);
final ticketsAsync = ref.watch(ticketsProvider); final ticketsAsync = ref.watch(ticketsProvider);
final officesAsync = ref.watch(officesProvider); final officesAsync = ref.watch(officesProvider);
final profileAsync = ref.watch(currentProfileProvider); final profileAsync = ref.watch(currentProfileProvider);
@ -255,6 +265,7 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
ref.read(tasksQueryProvider.notifier).state = ref.read(tasksQueryProvider.notifier).state =
const TaskQuery(offset: 0, limit: 50); const TaskQuery(offset: 0, limit: 50);
}, },
onPageChanged: null,
isLoading: false, isLoading: false,
columns: [ columns: [
TasQColumn<Task>( TasQColumn<Task>(
@ -433,18 +444,18 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
), ),
), ),
Expanded( Expanded(
child: DefaultTabController(
length: 2,
child: Column( child: Column(
children: [ children: [
const TabBar( TabBar(
tabs: [ controller: _tabController,
tabs: const [
Tab(text: 'My Tasks'), Tab(text: 'My Tasks'),
Tab(text: 'All Tasks'), Tab(text: 'All Tasks'),
], ],
), ),
Expanded( Expanded(
child: TabBarView( child: TabBarView(
controller: _tabController,
children: [ children: [
makeList(myTasks), makeList(myTasks),
makeList(filteredTasks), makeList(filteredTasks),
@ -454,7 +465,6 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
], ],
), ),
), ),
),
], ],
); );
}, },

View File

@ -181,6 +181,11 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
ref.read(ticketsQueryProvider.notifier).state = ref.read(ticketsQueryProvider.notifier).state =
const TicketQuery(offset: 0, limit: 50); const TicketQuery(offset: 0, limit: 50);
}, },
onPageChanged: (firstRow) {
ref
.read(ticketsQueryProvider.notifier)
.update((q) => q.copyWith(offset: firstRow));
},
isLoading: false, isLoading: false,
columns: [ columns: [
TasQColumn<Ticket>( TasQColumn<Ticket>(

View File

@ -51,10 +51,13 @@ class TasQAdaptiveList<T> extends StatelessWidget {
this.rowActions, this.rowActions,
this.onRowTap, this.onRowTap,
this.rowsPerPage = 50, this.rowsPerPage = 50,
this.totalCount,
this.pageOffset = 0,
this.tableHeader, this.tableHeader,
this.filterHeader, this.filterHeader,
this.summaryDashboard, this.summaryDashboard,
this.onRequestRefresh, this.onRequestRefresh,
this.onPageChanged,
this.isLoading = false, this.isLoading = false,
}); });
@ -78,6 +81,15 @@ class TasQAdaptiveList<T> extends StatelessWidget {
/// Per CLAUDE.md: Standard page size is 50 items for Desktop. /// Per CLAUDE.md: Standard page size is 50 items for Desktop.
final int rowsPerPage; 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. /// Optional header widget for the desktop table.
final Widget? tableHeader; final Widget? tableHeader;
@ -87,9 +99,21 @@ class TasQAdaptiveList<T> extends StatelessWidget {
/// Optional summary dashboard widget (e.g., status counts). /// Optional summary dashboard widget (e.g., status counts).
final Widget? summaryDashboard; 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; 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. /// If true, shows a loading indicator for server-side pagination.
final bool isLoading; final bool isLoading;
@ -228,6 +252,8 @@ class TasQAdaptiveList<T> extends StatelessWidget {
columns: columns, columns: columns,
rowActions: rowActions, rowActions: rowActions,
onRowTap: onRowTap, onRowTap: onRowTap,
offset: pageOffset,
totalCount: totalCount,
); );
// Use progressively smaller fractions of the viewport on larger screens // Use progressively smaller fractions of the viewport on larger screens
@ -248,7 +274,7 @@ class TasQAdaptiveList<T> extends StatelessWidget {
); );
final effectiveRowsPerPage = math.min( final effectiveRowsPerPage = math.min(
rowsPerPage, rowsPerPage,
math.max(1, items.length), math.max(1, totalCount ?? items.length),
); );
// wrap horizontal scroll with a visible scrollbar on desktop. the // wrap horizontal scroll with a visible scrollbar on desktop. the
@ -278,6 +304,7 @@ class TasQAdaptiveList<T> extends StatelessWidget {
if (rowActions != null) const DataColumn(label: Text('Actions')), if (rowActions != null) const DataColumn(label: Text('Actions')),
], ],
source: dataSource, source: dataSource,
onPageChanged: onPageChanged,
), ),
), ),
), ),
@ -360,6 +387,8 @@ class _TasQTableSource<T> extends DataTableSource {
required this.columns, required this.columns,
required this.rowActions, required this.rowActions,
required this.onRowTap, required this.onRowTap,
this.offset = 0,
this.totalCount,
}); });
final BuildContext context; final BuildContext context;
@ -367,11 +396,19 @@ class _TasQTableSource<T> extends DataTableSource {
final List<TasQColumn<T>> columns; final List<TasQColumn<T>> columns;
final TasQRowActions<T>? rowActions; final TasQRowActions<T>? rowActions;
final TasQRowTap<T>? onRowTap; final TasQRowTap<T>? onRowTap;
final int offset;
final int? totalCount;
@override @override
DataRow? getRow(int index) { DataRow? getRow(int index) {
if (index >= items.length) return null; // Map the global table index to the local items page using the
final item = items[index]; // 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 = <DataCell>[]; final cells = <DataCell>[];
for (final column in columns) { for (final column in columns) {
@ -396,7 +433,7 @@ class _TasQTableSource<T> extends DataTableSource {
bool get isRowCountApproximate => false; bool get isRowCountApproximate => false;
@override @override
int get rowCount => items.length; int get rowCount => totalCount ?? items.length;
@override @override
int get selectedRowCount => 0; int get selectedRowCount => 0;