Files
superport_v2/lib/widgets/components/superport_detail_dialog.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

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;
}
}