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 sections; /// 상단 개요 블록. 이름/설명 등 자유롭게 구성한다. final Widget? summary; /// 요약 우측 또는 하단에 배치할 배지 목록. final List summaryBadges; /// 개요 하단에 정렬되는 메타 정보 모음. final List 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 = []; 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 showSuperportDetailDialog({ required BuildContext context, required String title, List sections = const [], Widget? summary, List summaryBadges = const [], List metadata = const [], Widget? emptyPlaceholder, String? description, List 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 Function()? onSubmit, VoidCallback? onClose, bool showCloseButton = true, bool enableFocusTrap = true, List? headerActions, }) { return showSuperportDialog( 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 summaryBadges; final List 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 = []; 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 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 sections; final String? initialSectionId; final int initialSectionIndex; final EdgeInsetsGeometry padding; @override State<_TabbedSections> createState() => _TabbedSectionsState(); } class _TabbedSectionsState extends State<_TabbedSections> { late ShadTabsController _controller; @override void initState() { super.initState(); _controller = ShadTabsController(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( controller: _controller, expandContent: false, gap: 12, tabs: widget.sections .map( (section) => ShadTab( 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; } }