362 lines
9.7 KiB
Dart
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,
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|