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 'mono_text.dart'; /// A column configuration for the [TasQAdaptiveList] desktop table view. class TasQColumn { /// 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 = Widget Function(BuildContext context, T item, List actions); /// Returns a list of action widgets for a given item. typedef TasQRowActions = List Function(T item); /// Callback when a row is tapped. typedef TasQRowTap = 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 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 items; /// The column configurations for the desktop table view. final List> columns; /// Builds the mobile tile for each item. final TasQMobileTileBuilder mobileTileBuilder; /// Returns action widgets for each row (e.g., edit/delete buttons). final TasQRowActions? rowActions; /// Callback when a row is tapped. final TasQRowTap? 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 []; return _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 []; return _MobileTile( item: item, actions: actions, mobileTileBuilder: mobileTileBuilder, onRowTap: onRowTap, ); }, shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), ); final summarySection = summaryDashboard == null ? null : [ SizedBox(width: double.infinity, child: summaryDashboard!), const SizedBox(height: 12), ]; final filterSection = filterHeader == null ? null : [ 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( 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: SizedBox( height: 72, child: Card( margin: EdgeInsets.zero, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row( children: [ Container( width: 40, height: 40, decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(6), ), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container(height: 12, color: Colors.white), const SizedBox(height: 8), Container(height: 10, width: 150, color: Colors.white), ], ), ), ], ), ), ), ), ); } 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( 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 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 items; final List> columns; final TasQRowActions? rowActions; final TasQRowTap? 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> createState() => _DesktopTableViewState(); } class _DesktopTableViewState extends State<_DesktopTableView> { 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( 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 extends StatelessWidget { const _MobileTile({ required this.item, required this.actions, required this.mobileTileBuilder, required this.onRowTap, }); final T item; final List actions; final TasQMobileTileBuilder mobileTileBuilder; final TasQRowTap? 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 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 items; final List> columns; final TasQRowActions? rowActions; final TasQRowTap? 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 = []; 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( BuildContext context, Widget child, TasQColumn 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); }