Files
superport_v2/lib/widgets/components/superport_table.dart

353 lines
11 KiB
Dart

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;
/// 오름차순 여부. `false`면 내림차순이다.
final bool ascending;
}
/// 테이블 페이지네이션 정보를 보관하는 모델.
class SuperportTablePagination {
const SuperportTablePagination({
required this.currentPage,
required this.totalPages,
required this.totalItems,
required this.pageSize,
this.pageSizeOptions = const <int>[10, 20, 50],
});
/// 현재 페이지 번호(1-base).
final int currentPage;
/// 전체 페이지 수.
final int totalPages;
/// 전체 데이터 건수.
final int totalItems;
/// 현재 페이지네이션에서 선택된 페이지 크기.
final int pageSize;
/// 사용자에게 노출할 페이지 크기 옵션 목록.
final List<int> pageSizeOptions;
}
/// ShadTable.list를 감싼 공통 테이블 래퍼.
class SuperportTable extends StatelessWidget {
const SuperportTable({
super.key,
required List<Widget> columns,
required List<List<Widget>> 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;
/// 헤더와 행을 [ShadTableCell] 단위로 직접 전달할 때 사용하는 생성자.
const SuperportTable.fromCells({
super.key,
required List<ShadTableCell> header,
required List<List<ShadTableCell>> 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<Widget>? _columns;
final List<List<Widget>>? _rows;
final List<ShadTableCell>? _headerCells;
final List<List<ShadTableCell>>? _rowCells;
final TableSpanExtent? Function(int index)? columnSpanExtent;
final double rowHeight;
final double? maxHeight;
final void Function(int index)? onRowTap;
final String emptyLabel;
final Set<int>? 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<ShadTableCell> headerCells;
late final List<List<ShadTableCell>> 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 <int>{};
headerCells = <ShadTableCell>[];
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 int totalPages =
pagination.totalPages <= 0 ? 1 : pagination.totalPages;
final int currentPage = pagination.currentPage < 1
? 1
: (pagination.currentPage > totalPages
? totalPages
: pagination.currentPage);
final canGoPrev = currentPage > 1 && !isLoading;
final canGoNext = currentPage < totalPages && !isLoading;
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
SizedBox(
width: 170,
child: ShadSelect<int>(
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 / $totalPages',
style: theme.textTheme.small,
),
const SizedBox(width: 12),
ShadButton.ghost(
size: ShadButtonSize.sm,
onPressed: canGoPrev ? () => onPageChange?.call(1) : null,
child: const Icon(lucide.LucideIcons.chevronsLeft, size: 16),
),
const SizedBox(width: 8),
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),
),
const SizedBox(width: 8),
ShadButton.ghost(
size: ShadButtonSize.sm,
onPressed:
canGoNext ? () => onPageChange?.call(totalPages) : null,
child: const Icon(lucide.LucideIcons.chevronsRight, size: 16),
),
],
),
],
);
}
}