328 lines
8.8 KiB
Dart
328 lines
8.8 KiB
Dart
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<Widget>? actions;
|
|
final EdgeInsetsGeometry? contentPadding;
|
|
final Widget? header;
|
|
final Widget? footer;
|
|
final bool showCloseButton;
|
|
final VoidCallback? onClose;
|
|
final bool scrollable;
|
|
final EdgeInsets? insetPadding;
|
|
final FutureOr<void> Function()? onSubmit;
|
|
final bool enableFocusTrap;
|
|
|
|
static Future<T?> show<T>({
|
|
required BuildContext context,
|
|
required SuperportDialog dialog,
|
|
bool barrierDismissible = true,
|
|
}) {
|
|
return showDialog<T>(
|
|
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<Widget>().toList();
|
|
if (filtered.isEmpty) {
|
|
return null;
|
|
}
|
|
return _DialogFooter(children: filtered);
|
|
}
|
|
|
|
final fallback = <Widget>[
|
|
if (secondaryAction != null) secondaryAction!,
|
|
primaryAction ??
|
|
ShadButton.ghost(
|
|
onPressed: onClose ?? () => Navigator.of(context).maybePop(),
|
|
child: const Text('닫기'),
|
|
),
|
|
].whereType<Widget>().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<Widget> 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<T?> showSuperportDialog<T>({
|
|
required BuildContext context,
|
|
required String title,
|
|
String? description,
|
|
required Widget body,
|
|
Widget? primaryAction,
|
|
Widget? secondaryAction,
|
|
List<Widget>? actions,
|
|
bool mobileFullscreen = true,
|
|
bool barrierDismissible = true,
|
|
BoxConstraints? constraints,
|
|
EdgeInsetsGeometry? contentPadding,
|
|
bool scrollable = true,
|
|
bool showCloseButton = true,
|
|
VoidCallback? onClose,
|
|
FutureOr<void> Function()? onSubmit,
|
|
bool enableFocusTrap = true,
|
|
}) {
|
|
return SuperportDialog.show<T>(
|
|
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,
|
|
),
|
|
);
|
|
}
|