import 'dart:async'; import 'package:flutter/material.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide; import 'package:shadcn_ui/shadcn_ui.dart'; import 'keyboard_shortcuts.dart'; const double _kDialogMaxWidth = 640; const double _kDialogMobileBreakpoint = 640; const EdgeInsets _kDialogDesktopInset = EdgeInsets.symmetric( horizontal: 24, vertical: 32, ); const EdgeInsets _kDialogBodyPadding = EdgeInsets.symmetric( horizontal: 20, vertical: 24, ); /// 공통 모달 다이얼로그 scaffold. /// /// - ShadCard 기반으로 헤더/본문/푸터 영역을 분리한다. /// - 모바일에서는 전체 화면으로 확장되며 시스템 인셋을 자동 반영한다. /// - 닫기 버튼 및 사용자 정의 액션을 지원한다. class SuperportDialog extends StatelessWidget { const SuperportDialog({ super.key, required this.title, this.description, this.child = const SizedBox.shrink(), this.primaryAction, this.secondaryAction, this.mobileFullscreen = true, this.constraints, this.actions, this.contentPadding, this.header, this.footer, this.showCloseButton = true, this.onClose, this.scrollable = true, this.insetPadding, this.onSubmit, this.enableFocusTrap = true, }); final String title; final String? description; final Widget child; final Widget? primaryAction; final Widget? secondaryAction; final bool mobileFullscreen; final BoxConstraints? constraints; final List? actions; final EdgeInsetsGeometry? contentPadding; final Widget? header; final Widget? footer; final bool showCloseButton; final VoidCallback? onClose; final bool scrollable; final EdgeInsets? insetPadding; final FutureOr Function()? onSubmit; final bool enableFocusTrap; static Future show({ required BuildContext context, required SuperportDialog dialog, bool barrierDismissible = true, }) { return showDialog( context: context, barrierDismissible: barrierDismissible, builder: (_) => dialog, ); } @override Widget build(BuildContext context) { final mediaQuery = MediaQuery.of(context); final theme = ShadTheme.of(context); final screenWidth = mediaQuery.size.width; final isMobile = screenWidth <= _kDialogMobileBreakpoint; void handleClose() { if (onClose != null) { onClose!(); return; } Navigator.of(context).maybePop(); } final resolvedHeader = header ?? _SuperportDialogHeader( title: title, description: description, showCloseButton: showCloseButton, onClose: handleClose, ); final resolvedFooter = footer ?? _buildFooter(context); final card = ShadCard( padding: EdgeInsets.zero, child: ClipRRect( borderRadius: theme.radius, child: Column( mainAxisSize: MainAxisSize.min, children: [ resolvedHeader, Flexible( child: _DialogBody( padding: contentPadding ?? _kDialogBodyPadding, scrollable: scrollable, child: child, ), ), if (resolvedFooter != null) resolvedFooter, ], ), ), ); final resolvedConstraints = constraints ?? BoxConstraints( maxWidth: isMobile && mobileFullscreen ? screenWidth : _kDialogMaxWidth, minWidth: isMobile && mobileFullscreen ? screenWidth : 360, ); final resolvedInset = insetPadding ?? (isMobile && mobileFullscreen ? EdgeInsets.zero : _kDialogDesktopInset); return Dialog( insetPadding: resolvedInset, backgroundColor: Colors.transparent, surfaceTintColor: Colors.transparent, child: SafeArea( child: AnimatedPadding( duration: const Duration(milliseconds: 120), curve: Curves.easeOut, padding: mediaQuery.viewInsets, child: ConstrainedBox( constraints: resolvedConstraints, child: DialogKeyboardShortcuts( onEscape: handleClose, onSubmit: onSubmit, enableFocusTrap: enableFocusTrap, child: card, ), ), ), ), ); } Widget? _buildFooter(BuildContext context) { if (actions != null) { final filtered = actions!.whereType().toList(); if (filtered.isEmpty) { return null; } return _DialogFooter(children: filtered); } final fallback = [ if (secondaryAction != null) secondaryAction!, primaryAction ?? ShadButton.ghost( onPressed: onClose ?? () => Navigator.of(context).maybePop(), child: const Text('닫기'), ), ].whereType().toList(); if (fallback.isEmpty) { return null; } return _DialogFooter(children: fallback); } } class _DialogBody extends StatelessWidget { const _DialogBody({ required this.child, required this.padding, required this.scrollable, }); final Widget child; final EdgeInsetsGeometry padding; final bool scrollable; @override Widget build(BuildContext context) { final content = Padding(padding: padding, child: child); if (!scrollable) { return content; } return SingleChildScrollView(child: content); } } class _DialogFooter extends StatelessWidget { const _DialogFooter({required this.children}); final List children; @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 20), decoration: BoxDecoration( color: ShadTheme.of(context).colorScheme.muted, border: Border( top: BorderSide(color: ShadTheme.of(context).colorScheme.border), ), ), child: Row( mainAxisAlignment: MainAxisAlignment.end, children: [ for (var i = 0; i < children.length; i++) Padding( padding: EdgeInsets.only(left: i == 0 ? 0 : 12), child: children[i], ), ], ), ); } } class _SuperportDialogHeader extends StatelessWidget { const _SuperportDialogHeader({ required this.title, this.description, required this.showCloseButton, required this.onClose, }); final String title; final String? description; final bool showCloseButton; final VoidCallback onClose; @override Widget build(BuildContext context) { final theme = ShadTheme.of(context); return Container( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 20), decoration: BoxDecoration( color: theme.colorScheme.card, border: Border(bottom: BorderSide(color: theme.colorScheme.border)), ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text(title, style: theme.textTheme.h3), if (description != null) Padding( padding: const EdgeInsets.only(top: 8), child: Text(description!, style: theme.textTheme.muted), ), ], ), ), if (showCloseButton) IconButton( icon: const Icon(lucide.LucideIcons.x, size: 18), tooltip: '닫기', onPressed: onClose, ), ], ), ); } } /// Convenience wrapper around [SuperportDialog.show] to reduce boilerplate in pages. Future showSuperportDialog({ required BuildContext context, required String title, String? description, required Widget body, Widget? primaryAction, Widget? secondaryAction, List? actions, bool mobileFullscreen = true, bool barrierDismissible = true, BoxConstraints? constraints, EdgeInsetsGeometry? contentPadding, bool scrollable = true, bool showCloseButton = true, VoidCallback? onClose, FutureOr Function()? onSubmit, bool enableFocusTrap = true, }) { return SuperportDialog.show( context: context, barrierDismissible: barrierDismissible, dialog: SuperportDialog( title: title, description: description, primaryAction: primaryAction, secondaryAction: secondaryAction, actions: actions, constraints: constraints, mobileFullscreen: mobileFullscreen, contentPadding: contentPadding, scrollable: scrollable, showCloseButton: showCloseButton, onClose: onClose, onSubmit: onSubmit, enableFocusTrap: enableFocusTrap, child: body, ), ); }