- SuperportDetailDialog 위젯과 showSuperportDetailDialog 헬퍼를 추가하고 metadata/섹션 패턴을 표준화 - 결재/재고/마스터 각 상세 다이얼로그를 dialogs 디렉터리에 신설하고 기존 페이지를 신규 팝업으로 전환 - SuperportTable 행 선택과 우편번호 검색 다이얼로그 onRowTap 보정을 통해 헤더 오프셋 버그를 제거 - 상세 다이얼로그 및 트랜잭션/상세 뷰 전용 위젯 테스트와 tester_extensions 유틸을 추가하여 회귀를 방지 - detail_dialog_unification_plan.md로 작업 배경과 필드 통합 계획을 문서화
566 lines
16 KiB
Dart
566 lines
16 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:shadcn_ui/shadcn_ui.dart';
|
|
|
|
import 'superport_dialog.dart';
|
|
|
|
/// 엔티티 상세 정보를 탭 또는 단일 영역으로 보여주는 다이얼로그 전용 섹션 정의이다.
|
|
class SuperportDetailDialogSection {
|
|
const SuperportDetailDialogSection({
|
|
this.key,
|
|
required this.id,
|
|
required this.label,
|
|
required this.builder,
|
|
this.icon,
|
|
this.badge,
|
|
this.scrollable = true,
|
|
});
|
|
|
|
/// 탭 식별자. 초기 탭 선택에 사용된다.
|
|
final String id;
|
|
|
|
/// 탭 라벨에 사용될 텍스트.
|
|
final String label;
|
|
|
|
/// 섹션 컨텐츠를 생성하는 빌더.
|
|
final WidgetBuilder builder;
|
|
|
|
/// 라벨 좌측에 배치할 아이콘.
|
|
final IconData? icon;
|
|
|
|
/// 라벨 우측에 배치할 보조 배지 위젯.
|
|
final Widget? badge;
|
|
|
|
/// `true`면 섹션이 자체 스크롤 뷰를 생성한다.
|
|
final bool scrollable;
|
|
|
|
/// 탭 버튼에 부여할 키.
|
|
final Key? key;
|
|
}
|
|
|
|
/// 상세 요약을 표 형식으로 표현할 때 사용하는 메타 필드 정의이다.
|
|
class SuperportDetailMetadata {
|
|
const SuperportDetailMetadata({
|
|
required this.label,
|
|
required this.value,
|
|
this.icon,
|
|
});
|
|
|
|
/// 항목 이름.
|
|
final String label;
|
|
|
|
/// 값 영역에 들어갈 위젯. 기본 텍스트는 [SuperportDetailMetadata.text] 팩토리로 생성한다.
|
|
final Widget value;
|
|
|
|
/// 값 좌측에 배치할 아이콘.
|
|
final IconData? icon;
|
|
|
|
/// 간단한 문자열 값을 텍스트 위젯으로 감싼 메타데이터 팩토리이다.
|
|
factory SuperportDetailMetadata.text({
|
|
required String label,
|
|
required String value,
|
|
IconData? icon,
|
|
String? tooltip,
|
|
TextStyle? style,
|
|
TextAlign textAlign = TextAlign.start,
|
|
}) {
|
|
final text = Text(value, textAlign: textAlign, style: style);
|
|
|
|
final widget = tooltip == null
|
|
? text
|
|
: Tooltip(message: tooltip, child: text);
|
|
|
|
return SuperportDetailMetadata(label: label, value: widget, icon: icon);
|
|
}
|
|
}
|
|
|
|
/// 상세 다이얼로그 본문을 구성하는 레이아웃 위젯이다.
|
|
class SuperportDetailDialog extends StatelessWidget {
|
|
const SuperportDetailDialog({
|
|
super.key,
|
|
required this.sections,
|
|
this.summary,
|
|
this.metadata = const [],
|
|
this.summaryBadges = const [],
|
|
this.emptyPlaceholder,
|
|
this.initialSectionId,
|
|
this.initialSectionIndex = 0,
|
|
this.summaryPadding = const EdgeInsets.only(bottom: 16),
|
|
this.metadataPadding = EdgeInsets.zero,
|
|
this.infoPanelPadding = const EdgeInsets.only(bottom: 24),
|
|
this.sectionPadding = const EdgeInsets.symmetric(vertical: 4),
|
|
this.metadataColumns = 2,
|
|
});
|
|
|
|
/// 표시할 섹션 목록.
|
|
final List<SuperportDetailDialogSection> sections;
|
|
|
|
/// 상단 개요 블록. 이름/설명 등 자유롭게 구성한다.
|
|
final Widget? summary;
|
|
|
|
/// 요약 우측 또는 하단에 배치할 배지 목록.
|
|
final List<Widget> summaryBadges;
|
|
|
|
/// 개요 하단에 정렬되는 메타 정보 모음.
|
|
final List<SuperportDetailMetadata> metadata;
|
|
|
|
/// 섹션 데이터가 없을 때 출력할 플레이스홀더.
|
|
final Widget? emptyPlaceholder;
|
|
|
|
/// 최초로 선택할 섹션 ID.
|
|
final String? initialSectionId;
|
|
|
|
/// ID가 없을 때 사용할 기본 인덱스.
|
|
final int initialSectionIndex;
|
|
|
|
/// 요약 영역 패딩.
|
|
final EdgeInsetsGeometry summaryPadding;
|
|
|
|
/// 메타데이터 영역 패딩.
|
|
final EdgeInsetsGeometry metadataPadding;
|
|
|
|
/// summary+metadata 정보 블록 외부 패딩.
|
|
final EdgeInsetsGeometry infoPanelPadding;
|
|
|
|
/// 섹션 컨텐츠 패딩.
|
|
final EdgeInsetsGeometry sectionPadding;
|
|
|
|
/// metadata를 배치할 기본 열 수. 가로폭이 좁으면 자동으로 1열로 조정된다.
|
|
final int metadataColumns;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = ShadTheme.of(context);
|
|
return LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final children = <Widget>[];
|
|
|
|
final hasInfoPanel =
|
|
summary != null || summaryBadges.isNotEmpty || metadata.isNotEmpty;
|
|
|
|
if (hasInfoPanel) {
|
|
children.add(
|
|
Padding(
|
|
padding: infoPanelPadding,
|
|
child: _InfoPanel(
|
|
summary: summary,
|
|
summaryBadges: summaryBadges,
|
|
metadata: metadata,
|
|
summaryPadding: summaryPadding,
|
|
metadataPadding: metadataPadding,
|
|
metadataColumns: metadataColumns,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
final sectionContent = sections.isEmpty
|
|
? Center(
|
|
child:
|
|
emptyPlaceholder ??
|
|
Text(
|
|
'표시할 세부 정보가 없습니다.',
|
|
style: theme.textTheme.muted,
|
|
textAlign: TextAlign.center,
|
|
),
|
|
)
|
|
: sections.length == 1
|
|
? _SectionPane(section: sections.first, padding: sectionPadding)
|
|
: _TabbedSections(
|
|
sections: sections,
|
|
initialSectionId: initialSectionId,
|
|
initialSectionIndex: initialSectionIndex,
|
|
padding: sectionPadding,
|
|
);
|
|
|
|
children.add(sectionContent);
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: children,
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Superport 스타일 상세 다이얼로그를 표시하는 편의 함수이다.
|
|
Future<T?> showSuperportDetailDialog<T>({
|
|
required BuildContext context,
|
|
required String title,
|
|
List<SuperportDetailDialogSection> sections = const [],
|
|
Widget? summary,
|
|
List<Widget> summaryBadges = const [],
|
|
List<SuperportDetailMetadata> metadata = const [],
|
|
Widget? emptyPlaceholder,
|
|
String? description,
|
|
List<Widget> actions = const [],
|
|
bool barrierDismissible = true,
|
|
bool mobileFullscreen = true,
|
|
BoxConstraints? constraints,
|
|
EdgeInsetsGeometry? contentPadding,
|
|
EdgeInsetsGeometry infoPanelPadding = const EdgeInsets.only(bottom: 24),
|
|
int metadataColumns = 2,
|
|
String? initialSectionId,
|
|
int initialSectionIndex = 0,
|
|
FutureOr<void> Function()? onSubmit,
|
|
VoidCallback? onClose,
|
|
bool showCloseButton = true,
|
|
bool enableFocusTrap = true,
|
|
List<Widget>? headerActions,
|
|
}) {
|
|
return showSuperportDialog<T>(
|
|
context: context,
|
|
title: title,
|
|
description: description,
|
|
body: SuperportDetailDialog(
|
|
sections: sections,
|
|
summary: summary,
|
|
summaryBadges: summaryBadges,
|
|
metadata: metadata,
|
|
emptyPlaceholder: emptyPlaceholder,
|
|
initialSectionId: initialSectionId,
|
|
initialSectionIndex: initialSectionIndex,
|
|
infoPanelPadding: infoPanelPadding,
|
|
metadataColumns: metadataColumns,
|
|
),
|
|
actions: actions,
|
|
barrierDismissible: barrierDismissible,
|
|
mobileFullscreen: mobileFullscreen,
|
|
constraints:
|
|
constraints ?? const BoxConstraints(maxWidth: 720, minHeight: 320),
|
|
contentPadding:
|
|
contentPadding ??
|
|
const EdgeInsets.symmetric(horizontal: 24, vertical: 20),
|
|
scrollable: true,
|
|
onSubmit: onSubmit,
|
|
onClose: onClose,
|
|
showCloseButton: showCloseButton,
|
|
enableFocusTrap: enableFocusTrap,
|
|
headerActions: headerActions,
|
|
);
|
|
}
|
|
|
|
class _InfoPanel extends StatelessWidget {
|
|
const _InfoPanel({
|
|
required this.summary,
|
|
required this.summaryBadges,
|
|
required this.metadata,
|
|
required this.summaryPadding,
|
|
required this.metadataPadding,
|
|
required this.metadataColumns,
|
|
});
|
|
|
|
final Widget? summary;
|
|
final List<Widget> summaryBadges;
|
|
final List<SuperportDetailMetadata> metadata;
|
|
final EdgeInsetsGeometry summaryPadding;
|
|
final EdgeInsetsGeometry metadataPadding;
|
|
final int metadataColumns;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final hasSummary = summary != null || summaryBadges.isNotEmpty;
|
|
final hasMetadata = metadata.isNotEmpty;
|
|
final columnChildren = <Widget>[];
|
|
|
|
if (hasSummary) {
|
|
columnChildren.add(
|
|
Padding(
|
|
padding: summaryPadding,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
if (summary != null) summary!,
|
|
if (summary != null && summaryBadges.isNotEmpty)
|
|
const SizedBox(height: 12),
|
|
if (summaryBadges.isNotEmpty)
|
|
Wrap(spacing: 8, runSpacing: 8, children: summaryBadges),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
if (hasSummary && hasMetadata) {
|
|
columnChildren.add(const SizedBox(height: 12));
|
|
}
|
|
|
|
if (hasMetadata) {
|
|
columnChildren.add(
|
|
Padding(
|
|
padding: metadataPadding,
|
|
child: _MetadataGrid(metadata: metadata, columns: metadataColumns),
|
|
),
|
|
);
|
|
}
|
|
|
|
return ShadCard(
|
|
padding: const EdgeInsets.all(20),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: columnChildren,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _MetadataGrid extends StatelessWidget {
|
|
const _MetadataGrid({required this.metadata, required this.columns});
|
|
|
|
final List<SuperportDetailMetadata> metadata;
|
|
final int columns;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = ShadTheme.of(context);
|
|
|
|
return LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final mediaWidth = MediaQuery.of(context).size.width;
|
|
final maxWidth = constraints.maxWidth.isFinite
|
|
? constraints.maxWidth
|
|
: mediaWidth;
|
|
final normalizedColumns = columns <= 0 ? 1 : columns;
|
|
final maxColumns = metadata.isEmpty ? 1 : metadata.length;
|
|
final targetColumns = (normalizedColumns.clamp(1, maxColumns)).toInt();
|
|
final resolvedColumns = maxWidth < 520 ? 1 : targetColumns;
|
|
final spacing = resolvedColumns == 1 ? 0.0 : 16.0;
|
|
final tileWidth = resolvedColumns == 1
|
|
? maxWidth
|
|
: (maxWidth - spacing * (resolvedColumns - 1)) / resolvedColumns;
|
|
|
|
return Wrap(
|
|
spacing: 16,
|
|
runSpacing: 12,
|
|
children: metadata
|
|
.map(
|
|
(item) => SizedBox(
|
|
width: resolvedColumns == 1 ? maxWidth : tileWidth,
|
|
child: _MetadataTile(item: item, theme: theme),
|
|
),
|
|
)
|
|
.toList(),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class _MetadataTile extends StatelessWidget {
|
|
const _MetadataTile({required this.item, required this.theme});
|
|
|
|
final SuperportDetailMetadata item;
|
|
final ShadThemeData theme;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final labelStyle = theme.textTheme.small.copyWith(
|
|
fontWeight: FontWeight.w600,
|
|
color: theme.colorScheme.mutedForeground,
|
|
);
|
|
|
|
final valueStyle = theme.textTheme.small;
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
if (item.icon != null)
|
|
Padding(
|
|
padding: const EdgeInsets.only(right: 6),
|
|
child: Icon(
|
|
item.icon,
|
|
size: 16,
|
|
color: theme.colorScheme.mutedForeground,
|
|
),
|
|
),
|
|
Flexible(child: Text(item.label, style: labelStyle)),
|
|
],
|
|
),
|
|
const SizedBox(height: 4),
|
|
DefaultTextStyle.merge(style: valueStyle, child: item.value),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SectionPane extends StatefulWidget {
|
|
const _SectionPane({required this.section, required this.padding});
|
|
|
|
final SuperportDetailDialogSection section;
|
|
final EdgeInsetsGeometry padding;
|
|
|
|
@override
|
|
State<_SectionPane> createState() => _SectionPaneState();
|
|
}
|
|
|
|
class _SectionPaneState extends State<_SectionPane> {
|
|
ScrollController? _controller;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
if (widget.section.scrollable) {
|
|
_controller = ScrollController();
|
|
}
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(covariant _SectionPane oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
if (widget.section.scrollable && _controller == null) {
|
|
_controller = ScrollController();
|
|
} else if (!widget.section.scrollable && _controller != null) {
|
|
_controller!.dispose();
|
|
_controller = null;
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_controller?.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final content = Padding(
|
|
padding: widget.padding,
|
|
child: widget.section.builder(context),
|
|
);
|
|
|
|
if (!widget.section.scrollable) {
|
|
return content;
|
|
}
|
|
|
|
final controller = _controller ??= ScrollController();
|
|
|
|
return Scrollbar(
|
|
controller: controller,
|
|
thumbVisibility: true,
|
|
child: SingleChildScrollView(
|
|
controller: controller,
|
|
primary: false,
|
|
padding: const EdgeInsets.only(right: 4),
|
|
physics: const ClampingScrollPhysics(),
|
|
child: content,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _TabbedSections extends StatefulWidget {
|
|
const _TabbedSections({
|
|
required this.sections,
|
|
required this.initialSectionId,
|
|
required this.initialSectionIndex,
|
|
required this.padding,
|
|
});
|
|
|
|
final List<SuperportDetailDialogSection> sections;
|
|
final String? initialSectionId;
|
|
final int initialSectionIndex;
|
|
final EdgeInsetsGeometry padding;
|
|
|
|
@override
|
|
State<_TabbedSections> createState() => _TabbedSectionsState();
|
|
}
|
|
|
|
class _TabbedSectionsState extends State<_TabbedSections> {
|
|
late ShadTabsController<String> _controller;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_controller = ShadTabsController<String>(value: _resolveInitialSectionId());
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(covariant _TabbedSections oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
if (widget.sections.isEmpty) {
|
|
return;
|
|
}
|
|
|
|
final selected = _controller.selected;
|
|
final exists = widget.sections.any((section) => section.id == selected);
|
|
if (!exists) {
|
|
_controller.select(_resolveInitialSectionId());
|
|
return;
|
|
}
|
|
|
|
final nextInitial = widget.initialSectionId;
|
|
if (nextInitial != null && nextInitial != oldWidget.initialSectionId) {
|
|
final hasTarget = widget.sections.any(
|
|
(section) => section.id == nextInitial,
|
|
);
|
|
if (hasTarget) {
|
|
_controller.select(nextInitial);
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_controller.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ShadTabs<String>(
|
|
controller: _controller,
|
|
expandContent: false,
|
|
gap: 12,
|
|
tabs: widget.sections
|
|
.map(
|
|
(section) => ShadTab<String>(
|
|
key: section.key,
|
|
value: section.id,
|
|
content: _SectionPane(section: section, padding: widget.padding),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
if (section.icon != null)
|
|
Padding(
|
|
padding: const EdgeInsets.only(right: 6),
|
|
child: Icon(section.icon, size: 16),
|
|
),
|
|
Text(section.label),
|
|
if (section.badge != null) ...[
|
|
const SizedBox(width: 6),
|
|
section.badge!,
|
|
],
|
|
],
|
|
),
|
|
),
|
|
)
|
|
.toList(),
|
|
);
|
|
}
|
|
|
|
String _resolveInitialSectionId() {
|
|
if (widget.sections.isEmpty) {
|
|
return '';
|
|
}
|
|
|
|
if (widget.initialSectionId != null) {
|
|
final match = widget.sections.firstWhere(
|
|
(section) => section.id == widget.initialSectionId,
|
|
orElse: () => widget.sections.first,
|
|
);
|
|
return match.id;
|
|
}
|
|
|
|
final index = widget.initialSectionIndex.clamp(
|
|
0,
|
|
widget.sections.length - 1,
|
|
);
|
|
return widget.sections[index].id;
|
|
}
|
|
}
|