Fixed horizontal scrollbar, limit per page by 10 rows on desktop

Fixed duplicate entries in activity logs
This commit is contained in:
2026-03-02 20:11:49 +08:00
parent b9722106ff
commit 43d2bd4f95
3 changed files with 421 additions and 88 deletions
+239 -63
View File
@@ -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),
],
),
),
);
}