import 'dart:math' as math; 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 { /// 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.tableHeader, this.filterHeader, this.summaryDashboard, this.onRequestRefresh, this.isLoading = 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 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; if (isMobile) { 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.only(top: 8), child: SizedBox( height: 24, child: Center(child: CircularProgressIndicator(strokeWidth: 2)), ), ); } 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 Padding( padding: const EdgeInsets.only(top: 8), child: SizedBox( height: 24, child: Center(child: CircularProgressIndicator(strokeWidth: 2)), ), ); } 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 _buildDesktop(BuildContext context, BoxConstraints constraints) { final dataSource = _TasQTableSource( 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 : [ SizedBox(width: contentWidth, child: summaryDashboard!), const SizedBox(height: 12), ]; final filterSection = filterHeader == null ? null : [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 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); // 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 extends DataTableSource { /// Creates a table source for [TasQAdaptiveList]. _TasQTableSource({ required this.context, required this.items, required this.columns, required this.rowActions, required this.onRowTap, }); final BuildContext context; final List items; final List> columns; final TasQRowActions? rowActions; final TasQRowTap? onRowTap; @override DataRow? getRow(int index) { if (index >= items.length) return null; final item = items[index]; 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 => 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); }