Proper pagination
This commit is contained in:
parent
8e8269d12b
commit
eaabc0114c
|
|
@ -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;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,26 +444,25 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: DefaultTabController(
|
child: Column(
|
||||||
length: 2,
|
children: [
|
||||||
child: Column(
|
TabBar(
|
||||||
children: [
|
controller: _tabController,
|
||||||
const TabBar(
|
tabs: const [
|
||||||
tabs: [
|
Tab(text: 'My Tasks'),
|
||||||
Tab(text: 'My Tasks'),
|
Tab(text: 'All Tasks'),
|
||||||
Tab(text: 'All Tasks'),
|
],
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: TabBarView(
|
||||||
|
controller: _tabController,
|
||||||
|
children: [
|
||||||
|
makeList(myTasks),
|
||||||
|
makeList(filteredTasks),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Expanded(
|
),
|
||||||
child: TabBarView(
|
],
|
||||||
children: [
|
|
||||||
makeList(myTasks),
|
|
||||||
makeList(filteredTasks),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -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>(
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user