Files
superport_v2/lib/widgets/components/superport_table.dart
JiWoong Sul 2f8b529506 feat(dialog): 상세 팝업 SuperportDetailDialog 통합
- SuperportDetailDialog 위젯과 showSuperportDetailDialog 헬퍼를 추가하고 metadata/섹션 패턴을 표준화

- 결재/재고/마스터 각 상세 다이얼로그를 dialogs 디렉터리에 신설하고 기존 페이지를 신규 팝업으로 전환

- SuperportTable 행 선택과 우편번호 검색 다이얼로그 onRowTap 보정을 통해 헤더 오프셋 버그를 제거

- 상세 다이얼로그 및 트랜잭션/상세 뷰 전용 위젯 테스트와 tester_extensions 유틸을 추가하여 회귀를 방지

- detail_dialog_unification_plan.md로 작업 배경과 필드 통합 계획을 문서화
2025-11-07 19:02:43 +09:00

345 lines
10 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';
import 'package:superport_v2/widgets/components/superport_pagination_controls.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 hasHeaderRow = _headerCells != null || _columns != null;
final headerCount = hasHeaderRow ? 1 : 0;
void Function(int index)? handleRowTap;
if (onRowTap != null) {
handleRowTap = (index) {
final dataIndex = index - headerCount;
if (dataIndex < 0 || dataIndex >= tableRows.length) {
return;
}
onRowTap!(dataIndex);
};
}
final tableView = SizedBox(
height: effectiveHeight,
child: ShadTable.list(
header: headerCells,
columnSpanExtent: columnSpanExtent,
rowSpanExtent: (_) => FixedTableSpanExtent(rowHeight),
onRowTap: handleRowTap,
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);
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),
SuperportPaginationControls(
currentPage: currentPage,
totalPages: totalPages,
onPageSelected: onPageChange,
isBusy: isLoading,
),
],
),
],
);
}
}