import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide; import 'package:shadcn_ui/shadcn_ui.dart'; /// 테이블 정렬 상태 정보를 보관하는 모델. class SuperportTableSortState { const SuperportTableSortState({ required this.columnIndex, required this.ascending, }); final int columnIndex; final bool ascending; } /// 테이블 페이지네이션 정보를 보관하는 모델. class SuperportTablePagination { const SuperportTablePagination({ required this.currentPage, required this.totalPages, required this.totalItems, required this.pageSize, this.pageSizeOptions = const [10, 20, 50], }); final int currentPage; final int totalPages; final int totalItems; final int pageSize; final List pageSizeOptions; } /// ShadTable.list를 감싼 공통 테이블 래퍼. class SuperportTable extends StatelessWidget { const SuperportTable({ super.key, required List columns, required List> rows, this.columnSpanExtent, this.rowHeight = 56, this.maxHeight, this.onRowTap, this.emptyLabel = '데이터가 없습니다.', this.sortableColumns, this.sortState, this.onSortChanged, this.pagination, this.onPageChange, this.onPageSizeChange, this.isLoading = false, }) : _columns = columns, _rows = rows, _headerCells = null, _rowCells = null; const SuperportTable.fromCells({ super.key, required List header, required List> rows, this.columnSpanExtent, this.rowHeight = 56, this.maxHeight, this.onRowTap, this.emptyLabel = '데이터가 없습니다.', this.sortableColumns, this.sortState, this.onSortChanged, this.pagination, this.onPageChange, this.onPageSizeChange, this.isLoading = false, }) : _columns = null, _rows = null, _headerCells = header, _rowCells = rows; final List? _columns; final List>? _rows; final List? _headerCells; final List>? _rowCells; final TableSpanExtent? Function(int index)? columnSpanExtent; final double rowHeight; final double? maxHeight; final void Function(int index)? onRowTap; final String emptyLabel; final Set? sortableColumns; final SuperportTableSortState? sortState; final void Function(int columnIndex, bool ascending)? onSortChanged; final SuperportTablePagination? pagination; final void Function(int page)? onPageChange; final void Function(int pageSize)? onPageSizeChange; final bool isLoading; @override Widget build(BuildContext context) { late final List headerCells; late final List> tableRows; if (_rowCells case final rows?) { if (rows.isEmpty) { final theme = ShadTheme.of(context); return Padding( padding: const EdgeInsets.all(32), child: Center(child: Text(emptyLabel, style: theme.textTheme.muted)), ); } final header = _headerCells; if (header == null) { throw StateError('header cells must not be null when using fromCells'); } headerCells = [...header]; tableRows = rows; } else { final rows = _rows; if (rows == null || rows.isEmpty) { final theme = ShadTheme.of(context); return Padding( padding: const EdgeInsets.all(32), child: Center(child: Text(emptyLabel, style: theme.textTheme.muted)), ); } final columns = _columns!; final sortable = sortableColumns ?? const {}; headerCells = []; for (var i = 0; i < columns.length; i++) { final columnWidget = columns[i]; if (columnWidget is ShadTableCell) { headerCells.add(columnWidget); continue; } final shouldAttachSorter = onSortChanged != null && sortable.contains(i); final headerChild = shouldAttachSorter ? _SortableHeader( isActive: sortState?.columnIndex == i, ascending: sortState?.ascending ?? true, onTap: () { final isActive = sortState?.columnIndex == i; final nextAscending = isActive ? !(sortState?.ascending ?? true) : true; onSortChanged!(i, nextAscending); }, child: columnWidget, ) : columnWidget; headerCells.add(ShadTableCell.header(child: headerChild)); } tableRows = [ for (final row in rows) row .map( (cell) => cell is ShadTableCell ? cell : ShadTableCell(child: cell), ) .toList(), ]; } final estimatedHeight = (tableRows.length + 1) * rowHeight; final minHeight = rowHeight * 2; final effectiveHeight = math.max( minHeight, maxHeight == null ? estimatedHeight : math.min(estimatedHeight, maxHeight!), ); final tableView = SizedBox( height: effectiveHeight, child: ShadTable.list( header: headerCells, columnSpanExtent: columnSpanExtent, rowSpanExtent: (_) => FixedTableSpanExtent(rowHeight), onRowTap: onRowTap, primary: false, children: tableRows, ), ); final pagination = this.pagination; if (pagination == null) { return tableView; } return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ tableView, const SizedBox(height: 12), _PaginationFooter( pagination: pagination, onPageChange: onPageChange, onPageSizeChange: onPageSizeChange, isLoading: isLoading, ), ], ); } } /// 정렬 상태와 토글 동작을 제공하는 테이블 헤더 셀. class _SortableHeader extends StatelessWidget { const _SortableHeader({ required this.child, required this.onTap, required this.isActive, required this.ascending, }); final Widget child; final VoidCallback onTap; final bool isActive; final bool ascending; @override Widget build(BuildContext context) { final theme = ShadTheme.of(context); final icon = isActive ? (ascending ? lucide.LucideIcons.arrowUp : lucide.LucideIcons.arrowDown) : lucide.LucideIcons.arrowUpDown; final color = isActive ? theme.colorScheme.foreground : theme.colorScheme.mutedForeground; return InkWell( onTap: onTap, borderRadius: BorderRadius.circular(6), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Flexible(child: child), const SizedBox(width: 8), Icon(icon, size: 14, color: color), ], ), ), ); } } /// 페이지 이동 및 페이지 사이즈 변경을 처리하는 푸터 UI. class _PaginationFooter extends StatelessWidget { const _PaginationFooter({ required this.pagination, required this.onPageChange, required this.onPageSizeChange, required this.isLoading, }); final SuperportTablePagination pagination; final void Function(int page)? onPageChange; final void Function(int pageSize)? onPageSizeChange; final bool isLoading; @override Widget build(BuildContext context) { final theme = ShadTheme.of(context); final currentPage = pagination.currentPage.clamp(1, pagination.totalPages); final canGoPrev = currentPage > 1 && !isLoading; final canGoNext = currentPage < pagination.totalPages && !isLoading; return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ SizedBox( width: 170, child: ShadSelect( key: ValueKey(pagination.pageSize), initialValue: pagination.pageSize, selectedOptionBuilder: (_, value) => Text('$value개 / 페이지'), onChanged: isLoading ? null : (value) { if (value == null || value == pagination.pageSize) { return; } onPageSizeChange?.call(value); }, options: [ for (final option in pagination.pageSizeOptions) ShadOption(value: option, child: Text('$option개 / 페이지')), ], ), ), Row( children: [ Text( '${pagination.totalItems}건 · 페이지 $currentPage / ${pagination.totalPages}', style: theme.textTheme.small, ), const SizedBox(width: 12), ShadButton.ghost( size: ShadButtonSize.sm, onPressed: canGoPrev ? () => onPageChange?.call(currentPage - 1) : null, child: const Icon(lucide.LucideIcons.chevronLeft, size: 16), ), const SizedBox(width: 8), ShadButton.ghost( size: ShadButtonSize.sm, onPressed: canGoNext ? () => onPageChange?.call(currentPage + 1) : null, child: const Icon(lucide.LucideIcons.chevronRight, size: 16), ), ], ), ], ); } }