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 { 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 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> columns; final List 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( 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( 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 extends StatelessWidget { const _TableHeaderRow({ required this.columns, required this.sortColumn, required this.sortAscending, required this.onSort, }); final List> 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 extends StatelessWidget { const _TableDataRow({ required this.columns, required this.row, }); final List> 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, ); }, ); } }