- SuperportDetailDialog 위젯과 showSuperportDetailDialog 헬퍼를 추가하고 metadata/섹션 패턴을 표준화 - 결재/재고/마스터 각 상세 다이얼로그를 dialogs 디렉터리에 신설하고 기존 페이지를 신규 팝업으로 전환 - SuperportTable 행 선택과 우편번호 검색 다이얼로그 onRowTap 보정을 통해 헤더 오프셋 버그를 제거 - 상세 다이얼로그 및 트랜잭션/상세 뷰 전용 위젯 테스트와 tester_extensions 유틸을 추가하여 회귀를 방지 - detail_dialog_unification_plan.md로 작업 배경과 필드 통합 계획을 문서화
345 lines
10 KiB
Dart
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,
|
|
),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|