bharat_erp/lib/shared/widgets/app_data_table.dart
2026-07-13 15:22:41 +05:30

362 lines
9.7 KiB
Dart

import 'package:flutter/material.dart';
import 'app_card.dart';
/// Fixed height for every row in [AppDataTable] and themed [DataTable] widgets.
const double kAppTableRowHeight = 52;
class AppDataColumn<T> {
const AppDataColumn({
required this.label,
required this.cellBuilder,
this.sortKey,
this.flex = 1,
this.alignment = Alignment.centerLeft,
});
final String label;
final Widget Function(BuildContext context, T row) cellBuilder;
final String? sortKey;
final int flex;
final Alignment alignment;
}
/// 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 StatelessWidget {
const AppDataTable({
super.key,
required this.columns,
required this.rows,
this.sortColumn,
this.sortAscending = true,
this.onSort,
this.emptyMessage = 'No records found',
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 bool wrapInCard;
/// Set true when the table is placed inside another scrollable.
final bool shrinkWrap;
@override
Widget build(BuildContext context) {
if (rows.isEmpty) {
final empty = Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Text(
emptyMessage,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
);
if (!wrapInCard) return empty;
return AppCard(clipBehavior: Clip.antiAlias, child: empty);
}
final header = _TableHeaderRow<T>(
columns: columns,
sortColumn: sortColumn,
sortAscending: sortAscending,
onSort: onSort,
);
final body = ListView.builder(
padding: EdgeInsets.zero,
shrinkWrap: shrinkWrap,
physics: shrinkWrap ? const NeverScrollableScrollPhysics() : null,
itemCount: rows.length,
itemBuilder: (context, index) => _TableDataRow<T>(
columns: columns,
row: rows[index],
),
);
// Header stays fixed; only body rows scroll (when not shrink-wrapped).
final table = shrinkWrap
? Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
header,
body,
],
)
: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
header,
Expanded(child: body),
],
);
if (!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 SizedBox(
height: kAppTableRowHeight,
width: double.infinity,
child: DecoratedBox(
decoration: BoxDecoration(
// Opaque so scrolling rows never show through the sticky header.
color: Color.alphaBlend(
theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.55),
theme.colorScheme.surface,
),
border: Border(
bottom: BorderSide(
color: theme.colorScheme.outline.withValues(alpha: 0.12),
),
),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
children: columns.map((col) {
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,
),
);
Widget header = label;
if (col.sortKey != null && onSort != null) {
header = 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,
),
],
],
),
);
}
return Expanded(
flex: col.flex,
child: Align(
alignment: col.alignment,
child: header,
),
);
}).toList(),
),
),
),
);
}
}
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(
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: columns.map((col) {
return Expanded(
flex: col.flex,
child: Align(
alignment: col.alignment,
child: _TableCellSlot(
alignment: col.alignment,
child: col.cellBuilder(context, row),
),
),
);
}).toList(),
),
),
),
);
}
}
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,
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,
);
},
);
}
}