tasq/lib/widgets/tasq_adaptive_list.dart

424 lines
13 KiB
Dart

import 'dart:math' as math;
import 'package:flutter/material.dart';
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<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.tableHeader,
this.filterHeader,
this.summaryDashboard,
this.onRequestRefresh,
this.isLoading = 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 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 <Widget>[];
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 <Widget>[];
return _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 _buildDesktop(BuildContext context, BoxConstraints constraints) {
final dataSource = _TasQTableSource<T>(
context: context,
items: items,
columns: columns,
rowActions: rowActions,
onRowTap: onRowTap,
);
// 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;
final tableWidth = math.max(
contentWidth,
(columns.length + (rowActions == null ? 0 : 1)) * 140.0,
);
final effectiveRowsPerPage = math.min(
rowsPerPage,
math.max(1, 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')),
],
source: dataSource,
),
),
),
);
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)];
return SingleChildScrollView(
primary: true,
child: Center(
child: SizedBox(
key: const Key('adaptive_list_content'),
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<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);
// Apply Material 2 style elevation for Cards (per Hybrid M3/M2 guidelines).
// Mobile tiles deliberately use a slightly smaller corner radius for
// compactness, but they should inherit the global card elevation and
// shadow color from the theme to maintain visual consistency.
if (tile is Card) {
final themeCard = Theme.of(context).cardTheme;
return Card(
color: tile.color,
elevation: themeCard.elevation ?? 3,
margin: tile.margin,
// prefer the tile's explicit shape. For mobile tiles we intentionally
// use the compact radius token so list items feel denser while
// remaining theme-driven.
shape: tile.shape ?? AppSurfaces.of(context).compactShape,
shadowColor: AppSurfaces.of(context).compactShadowColor,
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,
});
final BuildContext context;
final List<T> items;
final List<TasQColumn<T>> columns;
final TasQRowActions<T>? rowActions;
final TasQRowTap<T>? onRowTap;
@override
DataRow? getRow(int index) {
if (index >= items.length) return null;
final item = items[index];
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 => 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);
}