Pagination
This commit is contained in:
+254
-112
@@ -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<T> {
|
||||
/// 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<T> =
|
||||
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);
|
||||
|
||||
/// Callback when a row is tapped.
|
||||
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 {
|
||||
/// Creates an adaptive list.
|
||||
const TasQAdaptiveList({
|
||||
super.key,
|
||||
required this.items,
|
||||
@@ -32,156 +49,281 @@ class TasQAdaptiveList<T> 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<T> items;
|
||||
|
||||
/// The column configurations for the desktop table view.
|
||||
final List<TasQColumn<T>> columns;
|
||||
|
||||
/// Builds the mobile tile for each item.
|
||||
final TasQMobileTileBuilder<T> mobileTileBuilder;
|
||||
|
||||
/// Returns action widgets for each row (e.g., edit/delete buttons).
|
||||
final TasQRowActions<T>? rowActions;
|
||||
|
||||
/// Callback when a row is tapped.
|
||||
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;
|
||||
|
||||
/// 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 <Widget>[];
|
||||
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 <Widget>[];
|
||||
return mobileTileBuilder(context, item, actions);
|
||||
},
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
);
|
||||
final summarySection = summaryDashboard == null
|
||||
? null
|
||||
: <Widget>[
|
||||
SizedBox(width: double.infinity, child: summaryDashboard!),
|
||||
const SizedBox(height: 12),
|
||||
];
|
||||
final filterSection = filterHeader == null
|
||||
? null
|
||||
: <Widget>[
|
||||
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<T>(
|
||||
context: context,
|
||||
items: items,
|
||||
columns: columns,
|
||||
rowActions: rowActions,
|
||||
final item = items[index];
|
||||
final actions = rowActions?.call(item) ?? const <Widget>[];
|
||||
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
|
||||
: <Widget>[
|
||||
SizedBox(width: contentWidth, child: summaryDashboard!),
|
||||
const SizedBox(height: 12),
|
||||
];
|
||||
final filterSection = filterHeader == null
|
||||
? null
|
||||
: <Widget>[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 <Widget>[];
|
||||
return _MobileTile(
|
||||
item: item,
|
||||
actions: actions,
|
||||
mobileTileBuilder: mobileTileBuilder,
|
||||
onRowTap: onRowTap,
|
||||
);
|
||||
},
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
);
|
||||
|
||||
final summarySection = summaryDashboard == null
|
||||
? null
|
||||
: <Widget>[
|
||||
SizedBox(width: double.infinity, child: summaryDashboard!),
|
||||
const SizedBox(height: 12),
|
||||
];
|
||||
final filterSection = filterHeader == null
|
||||
? null
|
||||
: <Widget>[
|
||||
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<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>(
|
||||
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
|
||||
: <Widget>[
|
||||
SizedBox(width: contentWidth, child: summaryDashboard!),
|
||||
const SizedBox(height: 12),
|
||||
];
|
||||
final filterSection = filterHeader == null
|
||||
? null
|
||||
: <Widget>[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<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 {
|
||||
/// Creates a table source for [TasQAdaptiveList].
|
||||
_TasQTableSource({
|
||||
required this.context,
|
||||
required this.items,
|
||||
|
||||
Reference in New Issue
Block a user