545 lines
17 KiB
Dart
545 lines
17 KiB
Dart
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<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>[];
|
||
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 <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 _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) {
|
||
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.
|
||
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, 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')),
|
||
],
|
||
source: dataSource,
|
||
onPageChanged: onPageChanged,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
|
||
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,
|
||
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);
|
||
}
|