import 'package:flutter/material.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; class SuperportShadTable extends StatefulWidget { final List data; final List> columns; final Function(T)? onRowTap; final Function(T)? onRowDoubleTap; final bool showCheckbox; final Set? selectedRows; final Function(Set)? onSelectionChanged; final bool sortable; final int rowsPerPage; final String? emptyMessage; final Widget? header; final Widget? footer; final bool loading; final bool striped; final bool hoverable; final ScrollController? scrollController; const SuperportShadTable({ super.key, required this.data, required this.columns, this.onRowTap, this.onRowDoubleTap, this.showCheckbox = false, this.selectedRows, this.onSelectionChanged, this.sortable = true, this.rowsPerPage = 10, this.emptyMessage, this.header, this.footer, this.loading = false, this.striped = true, this.hoverable = true, this.scrollController, }); @override State> createState() => _SuperportShadTableState(); } class _SuperportShadTableState extends State> { late List _sortedData; String? _sortColumn; bool _sortAscending = true; int _currentPage = 1; late Set _selectedRows; final ScrollController _defaultScrollController = ScrollController(); ScrollController get _scrollController => widget.scrollController ?? _defaultScrollController; @override void initState() { super.initState(); _sortedData = List.from(widget.data); _selectedRows = widget.selectedRows ?? {}; } @override void didUpdateWidget(SuperportShadTable oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.data != widget.data) { _sortedData = List.from(widget.data); _applySort(); } if (oldWidget.selectedRows != widget.selectedRows) { _selectedRows = widget.selectedRows ?? {}; } } @override void dispose() { _defaultScrollController.dispose(); super.dispose(); } void _sort(String columnKey) { if (!widget.sortable) return; setState(() { if (_sortColumn == columnKey) { _sortAscending = !_sortAscending; } else { _sortColumn = columnKey; _sortAscending = true; } _applySort(); }); } void _applySort() { if (_sortColumn == null) return; final column = widget.columns.firstWhere( (col) => col.key == _sortColumn, orElse: () => widget.columns.first, ); if (column.sorter != null) { _sortedData.sort((a, b) { final result = column.sorter!(a, b); return _sortAscending ? result : -result; }); } } void _toggleRowSelection(T row) { setState(() { if (_selectedRows.contains(row)) { _selectedRows.remove(row); } else { _selectedRows.add(row); } widget.onSelectionChanged?.call(_selectedRows); }); } void _toggleAllSelection() { setState(() { if (_selectedRows.length == _pageData.length) { _selectedRows.clear(); } else { _selectedRows = Set.from(_pageData); } widget.onSelectionChanged?.call(_selectedRows); }); } List get _pageData { final startIndex = (_currentPage - 1) * widget.rowsPerPage; final endIndex = startIndex + widget.rowsPerPage; return _sortedData.sublist( startIndex, endIndex.clamp(0, _sortedData.length), ); } int get _totalPages { return (_sortedData.length / widget.rowsPerPage).ceil(); } @override Widget build(BuildContext context) { final theme = ShadTheme.of(context); if (widget.loading) { return const Center( child: CircularProgressIndicator(), ); } if (_sortedData.isEmpty) { return Center( child: Padding( padding: const EdgeInsets.all(32), child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.inbox_outlined, size: 64, color: theme.colorScheme.mutedForeground, ), const SizedBox(height: 16), Text( widget.emptyMessage ?? '데이터가 없습니다', style: theme.textTheme.muted, ), ], ), ), ); } return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (widget.header != null) ...[ widget.header!, const SizedBox(height: 16), ], ShadCard( padding: EdgeInsets.zero, child: Column( children: [ // 테이블이 가용 너비를 최소한으로 채우되, 컬럼이 더 넓으면 수평 스크롤 허용 LayoutBuilder( builder: (context, constraints) { return SingleChildScrollView( controller: _scrollController, scrollDirection: Axis.horizontal, child: ConstrainedBox( constraints: BoxConstraints( // viewport(=부모)의 가로폭만큼은 항상 채움 minWidth: constraints.maxWidth, ), child: DataTable( columns: [ if (widget.showCheckbox) DataColumn( label: Checkbox( value: _selectedRows.length == _pageData.length && _pageData.isNotEmpty, onChanged: (_) => _toggleAllSelection(), ), ), ...widget.columns.map((column) { return DataColumn( label: InkWell( onTap: column.sorter != null && widget.sortable ? () => _sort(column.key) : null, child: Row( children: [ Text( column.label, style: theme.textTheme.small.copyWith( fontWeight: FontWeight.w600, ), ), if (column.sorter != null && widget.sortable) ...[ const SizedBox(width: 4), if (_sortColumn == column.key) Icon( _sortAscending ? Icons.arrow_upward : Icons.arrow_downward, size: 16, ) else const Icon( Icons.unfold_more, size: 16, ), ], ], ), ), ); }), ], rows: _pageData.asMap().entries.map((entry) { final index = entry.key; final row = entry.value; final isSelected = _selectedRows.contains(row); final isStriped = widget.striped && index.isOdd; return DataRow( selected: isSelected, color: WidgetStateProperty.resolveWith((states) { if (states.contains(WidgetState.selected)) { return theme.colorScheme.accent.withValues(alpha: 0.1); } if (widget.hoverable && states.contains(WidgetState.hovered)) { return theme.colorScheme.muted.withValues(alpha: 0.5); } if (isStriped) { return theme.colorScheme.muted.withValues(alpha: 0.3); } return null; }), onSelectChanged: widget.showCheckbox ? (_) => _toggleRowSelection(row) : null, cells: [ if (widget.showCheckbox) DataCell( Checkbox( value: isSelected, onChanged: (_) => _toggleRowSelection(row), ), ), ...widget.columns.map((column) { final cellValue = column.accessor(row); Widget cellWidget; if (column.render != null) { cellWidget = column.render!(row, cellValue); } else if (cellValue is DateTime) { cellWidget = Text( DateFormat('yyyy-MM-dd HH:mm').format(cellValue), style: theme.textTheme.small, ); } else if (cellValue is num) { cellWidget = Text( NumberFormat('#,###').format(cellValue), style: theme.textTheme.small, ); } else { cellWidget = Text( cellValue?.toString() ?? '-', style: theme.textTheme.small, ); } return DataCell( cellWidget, onTap: widget.onRowTap != null ? () => widget.onRowTap!(row) : null, onDoubleTap: widget.onRowDoubleTap != null ? () => widget.onRowDoubleTap!(row) : null, ); }), ], ); }).toList(), ), ), ); }, ), if (_totalPages > 1) ...[ const Divider(height: 1), Padding( padding: const EdgeInsets.all(16), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( '총 ${_sortedData.length}개 중 ' '${((_currentPage - 1) * widget.rowsPerPage) + 1}-' '${((_currentPage - 1) * widget.rowsPerPage) + _pageData.length}', style: theme.textTheme.muted, ), Row( children: [ ShadButton.outline( size: ShadButtonSize.sm, enabled: _currentPage > 1, onPressed: () { setState(() { _currentPage--; }); }, child: const Icon(Icons.chevron_left, size: 16), ), const SizedBox(width: 8), Text( '$_currentPage / $_totalPages', style: theme.textTheme.small, ), const SizedBox(width: 8), ShadButton.outline( size: ShadButtonSize.sm, enabled: _currentPage < _totalPages, onPressed: () { setState(() { _currentPage++; }); }, child: const Icon(Icons.chevron_right, size: 16), ), ], ), ], ), ), ], ], ), ), if (widget.footer != null) ...[ const SizedBox(height: 16), widget.footer!, ], ], ); } } class ShadTableColumn { final String key; final String label; final dynamic Function(T) accessor; final Widget Function(T, dynamic)? render; final int Function(T, T)? sorter; final double? width; final bool sortable; const ShadTableColumn({ required this.key, required this.label, required this.accessor, this.render, this.sorter, this.width, this.sortable = true, }); }