bharat_erp/lib/shared/widgets/app_data_table.dart
2026-07-17 14:35:10 +05:30

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,
);
},
);
}
}