feat(dialog): 상세 팝업 SuperportDetailDialog 통합
- SuperportDetailDialog 위젯과 showSuperportDetailDialog 헬퍼를 추가하고 metadata/섹션 패턴을 표준화 - 결재/재고/마스터 각 상세 다이얼로그를 dialogs 디렉터리에 신설하고 기존 페이지를 신규 팝업으로 전환 - SuperportTable 행 선택과 우편번호 검색 다이얼로그 onRowTap 보정을 통해 헤더 오프셋 버그를 제거 - 상세 다이얼로그 및 트랜잭션/상세 뷰 전용 위젯 테스트와 tester_extensions 유틸을 추가하여 회귀를 방지 - detail_dialog_unification_plan.md로 작업 배경과 필드 통합 계획을 문서화
This commit is contained in:
565
lib/widgets/components/superport_detail_dialog.dart
Normal file
565
lib/widgets/components/superport_detail_dialog.dart
Normal file
@@ -0,0 +1,565 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user