tasq/lib/widgets/tasq_adaptive_list.dart

545 lines
17 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 '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);
}