Fixed horizontal scrollbar, limit per page by 10 rows on desktop
Fixed duplicate entries in activity logs
This commit is contained in:
@@ -330,16 +330,6 @@ class TasQAdaptiveList<T> extends StatelessWidget {
|
||||
}
|
||||
|
||||
Widget _buildDesktop(BuildContext context, BoxConstraints constraints) {
|
||||
final dataSource = _TasQTableSource<T>(
|
||||
context: context,
|
||||
items: items,
|
||||
columns: columns,
|
||||
rowActions: rowActions,
|
||||
onRowTap: onRowTap,
|
||||
offset: pageOffset,
|
||||
totalCount: totalCount,
|
||||
);
|
||||
|
||||
// Use progressively smaller fractions of the viewport on larger screens
|
||||
// so that content doesn't stretch too widely, but also consume as much
|
||||
// width as possible when the display is more modest.
|
||||
@@ -352,70 +342,256 @@ class TasQAdaptiveList<T> extends StatelessWidget {
|
||||
contentFactor = 0.75; // ultra-wide monitors
|
||||
}
|
||||
final contentWidth = constraints.maxWidth * contentFactor;
|
||||
final tableWidth = math.max(
|
||||
contentWidth,
|
||||
(columns.length + (rowActions == null ? 0 : 1)) * 140.0,
|
||||
);
|
||||
final effectiveRowsPerPage = math.min(
|
||||
rowsPerPage,
|
||||
math.max(1, totalCount ?? items.length),
|
||||
);
|
||||
|
||||
// wrap horizontal scroll with a visible scrollbar on desktop. the
|
||||
// ScrollController is shared so the scrollbar has something to observe.
|
||||
final horizontalController = ScrollController();
|
||||
final tableWidget = Scrollbar(
|
||||
controller: horizontalController,
|
||||
thumbVisibility: true,
|
||||
trackVisibility: true,
|
||||
child: SingleChildScrollView(
|
||||
controller: horizontalController,
|
||||
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')),
|
||||
// Table width: mirrors PaginatedDataTable's own internal layout so the
|
||||
// horizontal scrollbar appears exactly when columns would otherwise be
|
||||
// squeezed below their minimum comfortable width.
|
||||
// • horizontalMargin=16 → 32 px total side padding
|
||||
// • columnSpacing=20 between each adjacent pair
|
||||
// • 200 px minimum per data column, 140 px for the actions column
|
||||
const double colMinW = 200.0;
|
||||
const double actMinW = 140.0;
|
||||
const double hMarginTotal = 16.0 * 2;
|
||||
const double colSpacing = 20.0;
|
||||
final actionsColumnCount = rowActions == null ? 0 : 1;
|
||||
final totalCols = columns.length + actionsColumnCount;
|
||||
final minColumnsWidth =
|
||||
hMarginTotal +
|
||||
(columns.length * colMinW) +
|
||||
(actionsColumnCount * actMinW) +
|
||||
(math.max(0, totalCols - 1) * colSpacing);
|
||||
final tableWidth = math.max(contentWidth, minColumnsWidth);
|
||||
|
||||
final summaryWidget = summaryDashboard == null
|
||||
? null
|
||||
: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(width: contentWidth, child: summaryDashboard!),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
source: dataSource,
|
||||
onPageChanged: onPageChanged,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
final filterWidget = filterHeader == null
|
||||
? null
|
||||
: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [filterHeader!, const SizedBox(height: 12)],
|
||||
);
|
||||
|
||||
return _DesktopTableView<T>(
|
||||
items: items,
|
||||
columns: columns,
|
||||
rowActions: rowActions,
|
||||
onRowTap: onRowTap,
|
||||
maxRowsPerPage: rowsPerPage,
|
||||
totalCount: totalCount,
|
||||
pageOffset: pageOffset,
|
||||
tableHeader: tableHeader,
|
||||
onPageChanged: onPageChanged,
|
||||
contentWidth: contentWidth,
|
||||
tableWidth: tableWidth,
|
||||
summaryWidget: summaryWidget,
|
||||
filterWidget: filterWidget,
|
||||
hasBoundedHeight: constraints.hasBoundedHeight,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stateful desktop table view that properly handles:
|
||||
/// - Auto-computed rows per page in bounded-height contexts (headers stay
|
||||
/// visible because the table always fits without vertical overflow).
|
||||
/// - Horizontal scrollbar only when table width exceeds viewport.
|
||||
/// - Clean [ScrollController] lifecycle.
|
||||
class _DesktopTableView<T> extends StatefulWidget {
|
||||
const _DesktopTableView({
|
||||
super.key,
|
||||
required this.items,
|
||||
required this.columns,
|
||||
required this.rowActions,
|
||||
required this.onRowTap,
|
||||
required this.maxRowsPerPage,
|
||||
required this.totalCount,
|
||||
required this.pageOffset,
|
||||
required this.tableHeader,
|
||||
required this.onPageChanged,
|
||||
required this.contentWidth,
|
||||
required this.tableWidth,
|
||||
required this.summaryWidget,
|
||||
required this.filterWidget,
|
||||
required this.hasBoundedHeight,
|
||||
});
|
||||
|
||||
final List<T> items;
|
||||
final List<TasQColumn<T>> columns;
|
||||
final TasQRowActions<T>? rowActions;
|
||||
final TasQRowTap<T>? onRowTap;
|
||||
final int maxRowsPerPage;
|
||||
final int? totalCount;
|
||||
final int pageOffset;
|
||||
final Widget? tableHeader;
|
||||
final void Function(int)? onPageChanged;
|
||||
final double contentWidth;
|
||||
final double tableWidth;
|
||||
final Widget? summaryWidget;
|
||||
final Widget? filterWidget;
|
||||
final bool hasBoundedHeight;
|
||||
|
||||
@override
|
||||
State<_DesktopTableView<T>> createState() => _DesktopTableViewState<T>();
|
||||
}
|
||||
|
||||
class _DesktopTableViewState<T> extends State<_DesktopTableView<T>> {
|
||||
final ScrollController _horizontalController = ScrollController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_horizontalController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Compute rows per page AND the per-row height from the exact pixel budget
|
||||
/// available for the table section, so the rendered table fills the space
|
||||
/// without leaving empty space below the pagination footer.
|
||||
///
|
||||
/// Returns a `(rowsPerPage, rowHeight)` record. [rowHeight] is ≥ 48 px
|
||||
/// (Flutter's `dataRowMinHeight` default) so rows never collapse.
|
||||
(int, double) _computeRowLayout(double availableHeight) {
|
||||
// PaginatedDataTable internal chrome heights (from Flutter source):
|
||||
// Heading row – 56 px
|
||||
// Footer row – 56 px
|
||||
// Optional card header – 64 px (only when tableHeader != null)
|
||||
// Card border/shadow – ~4 px
|
||||
const double colHeaderH = 56.0;
|
||||
const double footerH = 56.0;
|
||||
const double defaultRowH = 48.0;
|
||||
const double cardPad = 4.0;
|
||||
final double headerH = widget.tableHeader != null ? 64.0 : 0.0;
|
||||
|
||||
final overhead = colHeaderH + footerH + headerH + cardPad;
|
||||
final availableForRows = availableHeight - overhead;
|
||||
if (availableForRows <= 0) return (1, defaultRowH);
|
||||
|
||||
// How many rows fit at minimum height?
|
||||
final maxFitting = math.max(1, (availableForRows / defaultRowH).floor());
|
||||
// Never show blank rows — cap to the number of items we actually have.
|
||||
final itemCount = math.max(1, widget.items.length);
|
||||
final rows = math.min(
|
||||
maxFitting,
|
||||
math.min(widget.maxRowsPerPage, itemCount),
|
||||
);
|
||||
|
||||
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)];
|
||||
// Expand each row to fill the leftover space so the table reaches
|
||||
// exactly the bottom of the Expanded widget — no empty space.
|
||||
final rowHeight = math.max(defaultRowH, availableForRows / rows);
|
||||
return (rows, rowHeight);
|
||||
}
|
||||
|
||||
return SingleChildScrollView(
|
||||
primary: true,
|
||||
child: Center(
|
||||
Widget _buildTable(
|
||||
BuildContext context,
|
||||
int rowsPerPage, {
|
||||
double? rowHeight,
|
||||
}) {
|
||||
final dataSource = _TasQTableSource<T>(
|
||||
context: context,
|
||||
items: widget.items,
|
||||
columns: widget.columns,
|
||||
rowActions: widget.rowActions,
|
||||
onRowTap: widget.onRowTap,
|
||||
offset: widget.pageOffset,
|
||||
totalCount: widget.totalCount,
|
||||
);
|
||||
|
||||
final table = PaginatedDataTable(
|
||||
header: widget.tableHeader,
|
||||
rowsPerPage: rowsPerPage,
|
||||
columnSpacing: 20,
|
||||
horizontalMargin: 16,
|
||||
showCheckboxColumn: false,
|
||||
// When a rowHeight is provided (bounded layout), expand rows to fill
|
||||
// the exact available height so there is no empty space after paging.
|
||||
dataRowMinHeight: rowHeight ?? 48.0,
|
||||
dataRowMaxHeight: rowHeight ?? 48.0,
|
||||
headingRowColor: WidgetStateProperty.resolveWith(
|
||||
(states) => Theme.of(context).colorScheme.surfaceContainer,
|
||||
),
|
||||
columns: [
|
||||
for (final column in widget.columns)
|
||||
DataColumn(label: Text(column.header)),
|
||||
if (widget.rowActions != null) const DataColumn(label: Text('Actions')),
|
||||
],
|
||||
source: dataSource,
|
||||
onPageChanged: widget.onPageChanged,
|
||||
);
|
||||
|
||||
// Only wrap in a horizontal scroll + scrollbar when columns genuinely
|
||||
// exceed the available width.
|
||||
if (widget.tableWidth > widget.contentWidth) {
|
||||
return Scrollbar(
|
||||
controller: _horizontalController,
|
||||
thumbVisibility: true,
|
||||
trackVisibility: true,
|
||||
child: SingleChildScrollView(
|
||||
controller: _horizontalController,
|
||||
primary: false,
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: SizedBox(width: widget.tableWidth, child: table),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return table;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.hasBoundedHeight) {
|
||||
// Bounded: use Expanded around a LayoutBuilder so we know the *exact*
|
||||
// pixel budget for the table, then auto-compute rowsPerPage so the
|
||||
// table always fits vertically (→ headers never scroll away).
|
||||
return Center(
|
||||
child: SizedBox(
|
||||
key: const Key('adaptive_list_content'),
|
||||
width: contentWidth,
|
||||
width: widget.contentWidth,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [...?summarySection, ...?filterSection, tableWidget],
|
||||
children: [
|
||||
if (widget.summaryWidget != null) widget.summaryWidget!,
|
||||
if (widget.filterWidget != null) widget.filterWidget!,
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, tableConstraints) {
|
||||
final (rows, rowH) = _computeRowLayout(
|
||||
tableConstraints.maxHeight,
|
||||
);
|
||||
return _buildTable(context, rows, rowHeight: rowH);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Unbounded: use the requested rowsPerPage (capped to current page's
|
||||
// items.length to avoid blank-row padding at the bottom).
|
||||
final defaultRows = math.min(
|
||||
widget.maxRowsPerPage,
|
||||
math.max(1, widget.items.length),
|
||||
);
|
||||
return Center(
|
||||
child: SizedBox(
|
||||
key: const Key('adaptive_list_content'),
|
||||
width: widget.contentWidth,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (widget.summaryWidget != null) widget.summaryWidget!,
|
||||
if (widget.filterWidget != null) widget.filterWidget!,
|
||||
_buildTable(context, defaultRows),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user