657 lines
19 KiB
Dart
657 lines
19 KiB
Dart
import 'package:flutter/material.dart';
|
|
|
|
import 'app_card.dart';
|
|
|
|
/// Fixed height for every data row in [AppDataTable] and themed [DataTable] widgets.
|
|
const double kAppTableRowHeight = 44;
|
|
|
|
/// Fixed column-filter row height (first row under the header).
|
|
const double kAppTableFilterRowHeight = 44;
|
|
|
|
/// Horizontal gap between columns (header, filter row, and data cells).
|
|
const double kAppTableColumnGap = 12;
|
|
|
|
class AppDataColumn<T> {
|
|
const AppDataColumn({
|
|
required this.label,
|
|
required this.cellBuilder,
|
|
this.sortKey,
|
|
this.flex = 1,
|
|
this.alignment = Alignment.centerLeft,
|
|
this.padding = EdgeInsets.zero,
|
|
this.searchText,
|
|
this.enableSearch,
|
|
});
|
|
|
|
final String label;
|
|
final Widget Function(BuildContext context, T row) cellBuilder;
|
|
final String? sortKey;
|
|
final int flex;
|
|
final Alignment alignment;
|
|
final EdgeInsets padding;
|
|
|
|
/// Text used for this column's filter. When null and [enableSearch] is not
|
|
/// forced on, the column has no search input (e.g. Actions).
|
|
final String Function(T row)? searchText;
|
|
|
|
/// When null, search is enabled only if [searchText] is provided.
|
|
final bool? enableSearch;
|
|
|
|
bool get isSearchable => enableSearch ?? searchText != null;
|
|
}
|
|
|
|
/// Helpers for table cell content — single-line text with ellipsis and tooltip.
|
|
class AppTableCell {
|
|
AppTableCell._();
|
|
|
|
/// Renders [value] on one line; shows the full text in a tooltip when truncated.
|
|
static Widget text(
|
|
String? value, {
|
|
TextStyle? style,
|
|
String placeholder = '—',
|
|
TextAlign? textAlign,
|
|
bool showTooltip = true,
|
|
}) {
|
|
final display =
|
|
(value == null || value.trim().isEmpty) ? placeholder : value.trim();
|
|
return _EllipsisTooltipText(
|
|
text: display,
|
|
style: style,
|
|
textAlign: textAlign,
|
|
showTooltip: showTooltip && display != placeholder,
|
|
);
|
|
}
|
|
|
|
/// Wraps non-text cell widgets (chips, actions) inside the row height budget.
|
|
static Widget child(Widget widget) => widget;
|
|
}
|
|
|
|
class AppDataTable<T> extends StatefulWidget {
|
|
const AppDataTable({
|
|
super.key,
|
|
required this.columns,
|
|
required this.rows,
|
|
this.sortColumn,
|
|
this.sortAscending = true,
|
|
this.onSort,
|
|
this.emptyMessage = 'No records found',
|
|
this.noMatchMessage = 'No matching records',
|
|
this.wrapInCard = true,
|
|
this.shrinkWrap = false,
|
|
});
|
|
|
|
final List<AppDataColumn<T>> columns;
|
|
final List<T> rows;
|
|
final String? sortColumn;
|
|
final bool sortAscending;
|
|
final void Function(String column, bool ascending)? onSort;
|
|
final String emptyMessage;
|
|
final String noMatchMessage;
|
|
final bool wrapInCard;
|
|
|
|
/// Set true when the table is placed inside another scrollable.
|
|
final bool shrinkWrap;
|
|
|
|
@override
|
|
State<AppDataTable<T>> createState() => _AppDataTableState<T>();
|
|
}
|
|
|
|
class _AppDataTableState<T> extends State<AppDataTable<T>> {
|
|
/// Column index → search query (raw, including spaces until applied).
|
|
final Map<int, String> _queries = {};
|
|
final Map<int, TextEditingController> _controllers = {};
|
|
|
|
@override
|
|
void dispose() {
|
|
for (final c in _controllers.values) {
|
|
c.dispose();
|
|
}
|
|
super.dispose();
|
|
}
|
|
|
|
TextEditingController _controllerFor(int index) {
|
|
return _controllers.putIfAbsent(index, TextEditingController.new);
|
|
}
|
|
|
|
List<T> get _filteredRows {
|
|
final active = <int, String>{};
|
|
for (final entry in _queries.entries) {
|
|
final q = entry.value.trim().toLowerCase();
|
|
if (q.isEmpty) continue;
|
|
final col = widget.columns[entry.key];
|
|
if (!col.isSearchable || col.searchText == null) continue;
|
|
active[entry.key] = q;
|
|
}
|
|
if (active.isEmpty) return widget.rows;
|
|
|
|
return widget.rows.where((row) {
|
|
for (final entry in active.entries) {
|
|
final col = widget.columns[entry.key];
|
|
final value = (col.searchText!(row)).trim().toLowerCase();
|
|
if (!value.contains(entry.value)) return false;
|
|
}
|
|
return true;
|
|
}).toList();
|
|
}
|
|
|
|
bool get _hasActiveColumnFilters =>
|
|
_queries.values.any((q) => q.trim().isNotEmpty);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (widget.rows.isEmpty) {
|
|
final empty = Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(32),
|
|
child: Text(
|
|
widget.emptyMessage,
|
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
if (!widget.wrapInCard) return empty;
|
|
return AppCard(clipBehavior: Clip.antiAlias, child: empty);
|
|
}
|
|
|
|
final filtered = _filteredRows;
|
|
final showFilterRow = widget.columns.any((c) => c.isSearchable);
|
|
|
|
final header = _TableHeaderRow<T>(
|
|
columns: widget.columns,
|
|
sortColumn: widget.sortColumn,
|
|
sortAscending: widget.sortAscending,
|
|
onSort: widget.onSort,
|
|
);
|
|
|
|
final filterRow = showFilterRow
|
|
? _TableFilterRow<T>(
|
|
columns: widget.columns,
|
|
controllerFor: _controllerFor,
|
|
queryFor: (i) => _queries[i] ?? '',
|
|
onQueryChanged: (index, value) {
|
|
setState(() => _queries[index] = value);
|
|
},
|
|
)
|
|
: null;
|
|
|
|
final Widget body;
|
|
if (filtered.isEmpty) {
|
|
body = Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(32),
|
|
child: Text(
|
|
_hasActiveColumnFilters
|
|
? widget.noMatchMessage
|
|
: widget.emptyMessage,
|
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
} else {
|
|
body = ListView.builder(
|
|
padding: EdgeInsets.zero,
|
|
shrinkWrap: widget.shrinkWrap,
|
|
clipBehavior: Clip.none,
|
|
physics: widget.shrinkWrap
|
|
? const NeverScrollableScrollPhysics()
|
|
: null,
|
|
itemCount: filtered.length,
|
|
itemBuilder: (context, index) => _TableDataRow<T>(
|
|
columns: widget.columns,
|
|
row: filtered[index],
|
|
),
|
|
);
|
|
}
|
|
|
|
final pinned = <Widget>[
|
|
header,
|
|
if (filterRow != null) filterRow,
|
|
];
|
|
|
|
final table = widget.shrinkWrap
|
|
? Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
...pinned,
|
|
body,
|
|
],
|
|
)
|
|
: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
...pinned,
|
|
Expanded(
|
|
child: ClipRect(child: body),
|
|
),
|
|
],
|
|
);
|
|
|
|
if (!widget.wrapInCard) return table;
|
|
|
|
return AppCard(
|
|
clipBehavior: Clip.antiAlias,
|
|
child: table,
|
|
);
|
|
}
|
|
}
|
|
|
|
class _TableHeaderRow<T> extends StatelessWidget {
|
|
const _TableHeaderRow({
|
|
required this.columns,
|
|
required this.sortColumn,
|
|
required this.sortAscending,
|
|
required this.onSort,
|
|
});
|
|
|
|
final List<AppDataColumn<T>> columns;
|
|
final String? sortColumn;
|
|
final bool sortAscending;
|
|
final void Function(String column, bool ascending)? onSort;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
|
|
return Material(
|
|
color: theme.colorScheme.surfaceContainerHighest,
|
|
elevation: 1,
|
|
shadowColor: theme.colorScheme.shadow.withValues(alpha: 0.08),
|
|
child: SizedBox(
|
|
height: kAppTableRowHeight,
|
|
width: double.infinity,
|
|
child: DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
border: Border(
|
|
bottom: BorderSide(
|
|
color: theme.colorScheme.outline.withValues(alpha: 0.12),
|
|
),
|
|
),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
|
child: Row(
|
|
children: [
|
|
for (var i = 0; i < columns.length; i++) ...[
|
|
if (i > 0) const SizedBox(width: kAppTableColumnGap),
|
|
Expanded(
|
|
flex: columns[i].flex,
|
|
child: Padding(
|
|
padding: columns[i].padding,
|
|
child: Align(
|
|
alignment: columns[i].alignment,
|
|
child: _buildHeaderCell(
|
|
theme: theme,
|
|
col: columns[i],
|
|
sortColumn: sortColumn,
|
|
sortAscending: sortAscending,
|
|
onSort: onSort,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildHeaderCell({
|
|
required ThemeData theme,
|
|
required AppDataColumn<T> col,
|
|
required String? sortColumn,
|
|
required bool sortAscending,
|
|
required void Function(String column, bool ascending)? onSort,
|
|
}) {
|
|
final isSorted = col.sortKey != null && col.sortKey == sortColumn;
|
|
final label = Text(
|
|
col.label.toUpperCase(),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: theme.textTheme.labelSmall?.copyWith(
|
|
fontWeight: FontWeight.w700,
|
|
letterSpacing: 0.6,
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
);
|
|
|
|
if (col.sortKey == null || onSort == null) return label;
|
|
|
|
return InkWell(
|
|
onTap: () => onSort(
|
|
col.sortKey!,
|
|
isSorted ? !sortAscending : true,
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Flexible(child: label),
|
|
if (isSorted) ...[
|
|
const SizedBox(width: 4),
|
|
Icon(
|
|
sortAscending ? Icons.arrow_upward : Icons.arrow_downward,
|
|
size: 14,
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Fixed first row under the header — one search field per searchable column.
|
|
class _TableFilterRow<T> extends StatelessWidget {
|
|
const _TableFilterRow({
|
|
required this.columns,
|
|
required this.controllerFor,
|
|
required this.queryFor,
|
|
required this.onQueryChanged,
|
|
});
|
|
|
|
final List<AppDataColumn<T>> columns;
|
|
final TextEditingController Function(int index) controllerFor;
|
|
final String Function(int index) queryFor;
|
|
final void Function(int index, String value) onQueryChanged;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
|
|
return Material(
|
|
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.35),
|
|
child: SizedBox(
|
|
height: kAppTableFilterRowHeight,
|
|
width: double.infinity,
|
|
child: DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
border: Border(
|
|
bottom: BorderSide(
|
|
color: theme.colorScheme.outline.withValues(alpha: 0.12),
|
|
),
|
|
),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(20, 8, 20, 8),
|
|
child: Row(
|
|
children: [
|
|
for (var i = 0; i < columns.length; i++) ...[
|
|
if (i > 0) const SizedBox(width: kAppTableColumnGap),
|
|
Expanded(
|
|
flex: columns[i].flex,
|
|
child: Padding(
|
|
padding: columns[i].padding,
|
|
child: columns[i].isSearchable
|
|
? _ColumnSearchField(
|
|
controller: controllerFor(i),
|
|
query: queryFor(i),
|
|
onChanged: (v) => onQueryChanged(i, v),
|
|
)
|
|
: const SizedBox.shrink(),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ColumnSearchField extends StatelessWidget {
|
|
const _ColumnSearchField({
|
|
required this.controller,
|
|
required this.query,
|
|
required this.onChanged,
|
|
});
|
|
|
|
final TextEditingController controller;
|
|
final String query;
|
|
final ValueChanged<String> onChanged;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final outline = theme.colorScheme.outline.withValues(alpha: 0.28);
|
|
final hasQuery = query.trim().isNotEmpty;
|
|
|
|
return DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(8),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: theme.colorScheme.shadow.withValues(alpha: 0.04),
|
|
blurRadius: 4,
|
|
offset: const Offset(0, 1),
|
|
),
|
|
],
|
|
),
|
|
child: TextField(
|
|
controller: controller,
|
|
onChanged: onChanged,
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
cursorHeight: 14,
|
|
decoration: InputDecoration(
|
|
isDense: true,
|
|
hintText: 'Search…',
|
|
hintStyle: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.55),
|
|
fontWeight: FontWeight.w400,
|
|
),
|
|
prefixIcon: Icon(
|
|
Icons.search_rounded,
|
|
size: 16,
|
|
color: hasQuery
|
|
? theme.colorScheme.secondary
|
|
: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
|
),
|
|
prefixIconConstraints: const BoxConstraints(
|
|
minWidth: 34,
|
|
minHeight: 34,
|
|
),
|
|
suffixIcon: hasQuery
|
|
? IconButton(
|
|
tooltip: 'Clear',
|
|
visualDensity: VisualDensity.compact,
|
|
padding: EdgeInsets.zero,
|
|
constraints: const BoxConstraints(
|
|
minWidth: 28,
|
|
minHeight: 28,
|
|
),
|
|
icon: Icon(
|
|
Icons.close_rounded,
|
|
size: 14,
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
onPressed: () {
|
|
controller.clear();
|
|
onChanged('');
|
|
},
|
|
)
|
|
: null,
|
|
suffixIconConstraints: const BoxConstraints(
|
|
minWidth: 28,
|
|
minHeight: 28,
|
|
),
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: 10,
|
|
vertical: 8,
|
|
),
|
|
filled: true,
|
|
fillColor: theme.colorScheme.surface,
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
borderSide: BorderSide(color: outline),
|
|
),
|
|
enabledBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
borderSide: BorderSide(color: outline),
|
|
),
|
|
focusedBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
borderSide: BorderSide(
|
|
color: theme.colorScheme.primary,
|
|
width: 1.5,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _TableDataRow<T> extends StatelessWidget {
|
|
const _TableDataRow({
|
|
required this.columns,
|
|
required this.row,
|
|
});
|
|
|
|
final List<AppDataColumn<T>> columns;
|
|
final T row;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
|
|
return SizedBox(
|
|
height: kAppTableRowHeight,
|
|
width: double.infinity,
|
|
child: DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
color: theme.colorScheme.surface,
|
|
border: Border(
|
|
bottom: BorderSide(
|
|
color: theme.colorScheme.outline.withValues(alpha: 0.08),
|
|
),
|
|
),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
for (var i = 0; i < columns.length; i++) ...[
|
|
if (i > 0) const SizedBox(width: kAppTableColumnGap),
|
|
Expanded(
|
|
flex: columns[i].flex,
|
|
child: Padding(
|
|
padding: columns[i].padding,
|
|
child: Align(
|
|
alignment: columns[i].alignment,
|
|
child: _TableCellSlot(
|
|
alignment: columns[i].alignment,
|
|
child: columns[i].cellBuilder(context, row),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _TableCellSlot extends StatelessWidget {
|
|
const _TableCellSlot({
|
|
required this.child,
|
|
required this.alignment,
|
|
});
|
|
|
|
final Widget child;
|
|
final Alignment alignment;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
return SizedBox(
|
|
width: constraints.maxWidth,
|
|
child: Align(
|
|
alignment: alignment,
|
|
widthFactor: 1,
|
|
heightFactor: 1,
|
|
child: _coerceTableCell(child, context),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _coerceTableCell(Widget widget, BuildContext context) {
|
|
if (widget is Text) {
|
|
final text = widget.data ?? widget.textSpan?.toPlainText() ?? '';
|
|
if (text.isEmpty) return widget;
|
|
return AppTableCell.text(
|
|
text,
|
|
style: widget.style ?? DefaultTextStyle.of(context).style,
|
|
textAlign: widget.textAlign,
|
|
);
|
|
}
|
|
return widget;
|
|
}
|
|
}
|
|
|
|
class _EllipsisTooltipText extends StatelessWidget {
|
|
const _EllipsisTooltipText({
|
|
required this.text,
|
|
this.style,
|
|
this.textAlign,
|
|
this.showTooltip = true,
|
|
});
|
|
|
|
final String text;
|
|
final TextStyle? style;
|
|
final TextAlign? textAlign;
|
|
final bool showTooltip;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final effectiveStyle = style ?? DefaultTextStyle.of(context).style;
|
|
|
|
return LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final maxWidth = constraints.maxWidth;
|
|
final painter = TextPainter(
|
|
text: TextSpan(text: text, style: effectiveStyle),
|
|
maxLines: 1,
|
|
textDirection: Directionality.of(context),
|
|
textAlign: textAlign ?? TextAlign.start,
|
|
)..layout(maxWidth: maxWidth.isFinite ? maxWidth : double.infinity);
|
|
|
|
final overflows = maxWidth.isFinite &&
|
|
(painter.didExceedMaxLines || painter.width > maxWidth);
|
|
|
|
final textWidget = Text(
|
|
text,
|
|
style: effectiveStyle,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
textAlign: textAlign,
|
|
);
|
|
|
|
if (!showTooltip || !overflows) return textWidget;
|
|
|
|
return Tooltip(
|
|
message: text,
|
|
waitDuration: const Duration(milliseconds: 400),
|
|
child: textWidget,
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|