tasq/lib/widgets/tasq_adaptive_list.dart

722 lines
23 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:math' as math;
import 'package:flutter/material.dart';
// skeleton rendering is controlled by the caller's `Skeletonizer` wrapper
// so this widget doesn't import `skeletonizer` directly.
import '../theme/app_typography.dart';
import '../theme/app_surfaces.dart';
import '../theme/m3_motion.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,
required this.columns,
required this.mobileTileBuilder,
this.rowActions,
this.onRowTap,
this.rowsPerPage = 50,
this.totalCount,
this.pageOffset = 0,
this.tableHeader,
this.filterHeader,
this.summaryDashboard,
this.onRequestRefresh,
this.onPageChanged,
this.isLoading = false,
this.skeletonMode = 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 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.
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.
///
/// * **Mobile** invoked when the user scrolls to the end of the list.
/// * **Desktop** *not* triggered by page changes (see [onPageChanged]).
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.
final bool isLoading;
/// When true the widget renders skeleton placeholders for the
/// dashboard, filter panel and list items instead of the real content.
/// This is intended to provide a single-source skeleton UI for screens
/// that wrap the whole body in a `Skeletonizer` and want consistent
/// sectioned placeholders.
final bool skeletonMode;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final isMobile = constraints.maxWidth < 600;
if (isMobile) {
return _buildMobile(context, constraints);
}
return _buildDesktop(context, constraints);
},
);
}
Widget _buildMobile(BuildContext context, BoxConstraints constraints) {
final hasBoundedHeight = constraints.hasBoundedHeight;
if (skeletonMode) {
// Render structured skeleton sections: summary, filters, and list.
final summary = summaryDashboard == null
? const SizedBox.shrink()
: Column(
children: [
SizedBox(width: double.infinity, child: summaryDashboard!),
const SizedBox(height: 12),
],
);
final filter = filterHeader == null
? const SizedBox.shrink()
: Column(
children: [
ExpansionTile(
title: const Text('Filters'),
children: [
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: filterHeader!,
),
],
),
const SizedBox(height: 8),
],
);
final skeletonList = ListView.separated(
padding: const EdgeInsets.only(bottom: 24),
itemCount: 6,
separatorBuilder: (context, index) => const SizedBox(height: 12),
itemBuilder: (context, index) => _loadingTile(context),
);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (summaryDashboard != null) ...[summary],
if (filterHeader != null) ...[filter],
Expanded(child: _buildInfiniteScrollListener(skeletonList)),
],
),
);
}
// 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 skeleton for infinite scroll (non-blocking shimmer)
return _loadingTile(context);
}
final item = items[index];
final actions = rowActions?.call(item) ?? const <Widget>[];
// M3 Expressive: stagger first 8 items on enter (50 ms per step).
final staggerDelay = Duration(
milliseconds: math.min(index, 8) * 50,
);
return M3FadeSlideIn(
delay: staggerDelay,
child: _MobileTile(
item: item,
actions: actions,
mobileTileBuilder: mobileTileBuilder,
onRowTap: onRowTap,
),
);
},
);
// 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 _loadingTile(context);
}
final item = items[index];
final actions = rowActions?.call(item) ?? const <Widget>[];
final staggerDelay = Duration(
milliseconds: math.min(index, 8) * 50,
);
return M3FadeSlideIn(
delay: staggerDelay,
child: _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 _loadingTile(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(top: 8),
child: Card(
margin: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
M3ShimmerBox(
width: 40,
height: 40,
borderRadius: BorderRadius.circular(6),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
M3ShimmerBox(height: 12),
const SizedBox(height: 8),
M3ShimmerBox(width: 150, height: 10),
],
),
),
],
),
),
),
);
}
Widget _buildDesktop(BuildContext context, BoxConstraints constraints) {
// 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.
double contentFactor;
if (constraints.maxWidth < 1200) {
contentFactor = 0.95; // not-so-wide monitors
} else if (constraints.maxWidth < 1800) {
contentFactor = 0.85; // wide monitors
} else {
contentFactor = 0.75; // ultra-wide monitors
}
final contentWidth = constraints.maxWidth * contentFactor;
// 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),
],
);
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),
);
// 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);
}
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: widget.contentWidth,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
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),
],
),
),
);
}
}
/// Mobile tile wrapper that applies M3 Expressive tonal 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);
// M3 Expressive: cards use tonal surface tints. The theme's CardThemeData
// already specifies surfaceTintColor and low elevation. We apply the
// compact shape for list density.
if (tile is Card) {
return Card(
color: tile.color,
margin: tile.margin,
shape: tile.shape ?? AppSurfaces.of(context).compactShape,
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,
required this.columns,
required this.rowActions,
required this.onRowTap,
this.offset = 0,
this.totalCount,
});
final BuildContext context;
final List<T> items;
final List<TasQColumn<T>> columns;
final TasQRowActions<T>? rowActions;
final TasQRowTap<T>? onRowTap;
final int offset;
final int? totalCount;
@override
DataRow? getRow(int index) {
// Map the global table index to the local items page using the
// 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>[];
for (final column in columns) {
final widget = column.cellBuilder(context, item);
cells.add(DataCell(_applyTechnicalStyle(context, widget, column)));
}
if (rowActions != null) {
final actions = rowActions!.call(item);
cells.add(
DataCell(Row(mainAxisSize: MainAxisSize.min, children: actions)),
);
}
return DataRow(
onSelectChanged: onRowTap == null ? null : (_) => onRowTap!(item),
cells: cells,
);
}
@override
bool get isRowCountApproximate => false;
@override
int get rowCount => totalCount ?? items.length;
@override
int get selectedRowCount => 0;
}
Widget _applyTechnicalStyle<T>(
BuildContext context,
Widget child,
TasQColumn<T> column,
) {
if (!column.technical) return child;
if (child is Text && child.data != null) {
return MonoText(
child.data ?? '',
style: child.style,
maxLines: child.maxLines,
overflow: child.overflow,
textAlign: child.textAlign,
);
}
final mono = AppMonoText.of(context).body;
return DefaultTextStyle.merge(style: mono, child: child);
}