import 'package:flutter/material.dart'; import '../../core/utils/table_search.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 { const AppDataColumn({ required this.label, required this.cellBuilder, this.sortKey, this.flex = 1, this.width, 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; /// When set, column uses a fixed width instead of [flex]. final double? width; 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; } /// Shared column sizing: fixed [AppDataColumn.width] or flexible [AppDataColumn.flex]. Widget _appTableColumnSlot({ required AppDataColumn column, required Widget child, }) { final padded = Padding(padding: column.padding, child: child); final width = column.width; if (width != null) { return SizedBox(width: width, child: padded); } return Expanded(flex: column.flex, child: padded); } /// 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, ); } /// Clickable sequential number / code that navigates to a detail view. static Widget link( String? value, { required VoidCallback? onTap, TextStyle? style, String placeholder = '—', TextAlign? textAlign, bool underlined = true, }) { final display = (value == null || value.trim().isEmpty) ? placeholder : value.trim(); if (onTap == null || display == placeholder) { return text( display, style: style, placeholder: placeholder, textAlign: textAlign, ); } return Builder( builder: (context) { final theme = Theme.of(context); final linkStyle = (style ?? theme.textTheme.bodyMedium)?.copyWith( color: theme.colorScheme.primary, fontWeight: FontWeight.w600, decoration: underlined ? TextDecoration.underline : TextDecoration.none, decorationColor: underlined ? theme.colorScheme.primary.withValues(alpha: 0.45) : null, ); return MouseRegion( cursor: SystemMouseCursors.click, child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(4), child: _EllipsisTooltipText( text: display, style: linkStyle, textAlign: textAlign, showTooltip: true, ), ), ); }, ); } /// Wraps non-text cell widgets (chips, actions) inside the row height budget. static Widget child(Widget widget) => widget; } class AppDataTable 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, this.onServerSearchChanged, this.onEnsureFullDataset, this.onColumnSearchCleared, }); final List> columns; final List 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; /// Deprecated: prefer [onEnsureFullDataset] + [onColumnSearchCleared]. /// Kept so older call sites still compile; ignored for filtering. final ValueChanged? onServerSearchChanged; /// Called once when the first column filter becomes active. /// Parent should load all rows (`limit = total`) with no search query. final Future Function()? onEnsureFullDataset; /// Called when all column filters are cleared (or the table is disposed /// while filters were active). Parent should restore normal page size. final VoidCallback? onColumnSearchCleared; @override State> createState() => _AppDataTableState(); } class _AppDataTableState extends State> { /// Column index → search query (raw, including spaces until applied). final Map _queries = {}; final Map _controllers = {}; final SearchDebouncer _ensureDatasetDebouncer = SearchDebouncer( duration: const Duration(milliseconds: 150), ); bool _fullDatasetActive = false; bool _ensureInFlight = false; bool get _usesFullDatasetMode => widget.onEnsureFullDataset != null || widget.onColumnSearchCleared != null; @override void dispose() { _ensureDatasetDebouncer.dispose(); if (_fullDatasetActive || _ensureInFlight) { widget.onColumnSearchCleared?.call(); } for (final c in _controllers.values) { c.dispose(); } super.dispose(); } TextEditingController _controllerFor(int index) { return _controllers.putIfAbsent(index, TextEditingController.new); } bool get _hasActiveColumnFilters => _queries.values.any((q) => q.trim().isNotEmpty); Future _syncFullDatasetMode() async { if (!_usesFullDatasetMode) return; if (_hasActiveColumnFilters && !_fullDatasetActive) { final ensure = widget.onEnsureFullDataset; if (ensure != null && !_ensureInFlight) { _ensureInFlight = true; try { await ensure(); if (mounted && _hasActiveColumnFilters) { setState(() => _fullDatasetActive = true); } } finally { _ensureInFlight = false; } } return; } if (!_hasActiveColumnFilters && _fullDatasetActive) { _fullDatasetActive = false; widget.onColumnSearchCleared?.call(); } } void _onColumnQueryChanged(int index, String value) { setState(() { _queries[index] = value; }); if (!_usesFullDatasetMode) return; // Debounce ensure/clear so rapid typing doesn't thrash the API. _ensureDatasetDebouncer.run(value, (_) { _syncFullDatasetMode(); }); } List get _filteredRows { final active = {}; 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(); } @override Widget build(BuildContext context) { final showFilterRow = widget.columns.any((c) => c.isSearchable); final filtered = _filteredRows; // Keep header + column filters visible even when the API returns no rows, // so users can refine or clear a server-side search. if (widget.rows.isEmpty && !showFilterRow) { 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 header = _TableHeaderRow( columns: widget.columns, sortColumn: widget.sortColumn, sortAscending: widget.sortAscending, onSort: widget.onSort, ); final filterRow = showFilterRow ? _TableFilterRow( columns: widget.columns, controllerFor: _controllerFor, queryFor: (i) => _queries[i] ?? '', onQueryChanged: _onColumnQueryChanged, ) : 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( columns: widget.columns, row: filtered[index], ), ); } final pinned = [ 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 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 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), _appTableColumnSlot( column: columns[i], 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 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 extends StatelessWidget { const _TableFilterRow({ required this.columns, required this.controllerFor, required this.queryFor, required this.onQueryChanged, }); final List> 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), _appTableColumnSlot( column: columns[i], 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 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 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( 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), _appTableColumnSlot( column: columns[i], 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, ); }, ); } }