전역 구조 리팩터링 및 테스트 확장

This commit is contained in:
JiWoong Sul
2025-09-29 01:51:47 +09:00
parent c00c0c9ab2
commit fef7108479
70 changed files with 7709 additions and 3185 deletions

View File

@@ -35,15 +35,8 @@ class AppLayout extends StatelessWidget {
_BreadcrumbBar(items: breadcrumbs),
const SizedBox(height: 16),
],
PageHeader(
title: title,
subtitle: subtitle,
actions: actions,
),
if (toolbar != null) ...[
const SizedBox(height: 16),
toolbar!,
],
PageHeader(title: title, subtitle: subtitle, actions: actions),
if (toolbar != null) ...[const SizedBox(height: 16), toolbar!],
const SizedBox(height: 24),
child,
],
@@ -54,11 +47,7 @@ class AppLayout extends StatelessWidget {
}
class AppBreadcrumbItem {
const AppBreadcrumbItem({
required this.label,
this.path,
this.onTap,
});
const AppBreadcrumbItem({required this.label, this.path, this.onTap});
final String label;
final String? path;
@@ -94,7 +83,10 @@ class _BreadcrumbBar extends StatelessWidget {
size: 14,
color: colorScheme.mutedForeground,
),
_BreadcrumbChip(item: items[index], isLast: index == items.length - 1),
_BreadcrumbChip(
item: items[index],
isLast: index == items.length - 1,
),
],
],
);
@@ -113,7 +105,9 @@ class _BreadcrumbChip extends StatelessWidget {
final label = Text(
item.label,
style: theme.textTheme.small.copyWith(
color: isLast ? theme.colorScheme.foreground : theme.colorScheme.mutedForeground,
color: isLast
? theme.colorScheme.foreground
: theme.colorScheme.mutedForeground,
),
);

View File

@@ -1,8 +1,10 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide;
import '../core/constants/app_sections.dart';
import '../core/theme/theme_controller.dart';
import '../core/permissions/permission_manager.dart';
class AppShell extends StatelessWidget {
const AppShell({
@@ -19,6 +21,13 @@ class AppShell extends StatelessWidget {
return LayoutBuilder(
builder: (context, constraints) {
final isWide = constraints.maxWidth >= 960;
final manager = PermissionScope.of(context);
final filteredPages = <AppPageDescriptor>[
for (final section in appSections)
for (final page in section.pages)
if (manager.can(page.path, PermissionAction.view)) page,
];
final pages = filteredPages.isEmpty ? allAppPages : filteredPages;
if (isWide) {
return Scaffold(
appBar: AppBar(
@@ -26,14 +35,14 @@ class AppShell extends StatelessWidget {
actions: [
IconButton(
tooltip: '로그아웃',
icon: const Icon(LucideIcons.logOut),
icon: const Icon(lucide.LucideIcons.logOut),
onPressed: () => context.go(loginRoutePath),
),
],
),
body: Row(
children: [
_NavigationRail(currentLocation: currentLocation),
_NavigationRail(currentLocation: currentLocation, pages: pages),
const VerticalDivider(width: 1),
Expanded(child: child),
],
@@ -47,7 +56,7 @@ class AppShell extends StatelessWidget {
actions: [
IconButton(
tooltip: '로그아웃',
icon: const Icon(LucideIcons.logOut),
icon: const Icon(lucide.LucideIcons.logOut),
onPressed: () => context.go(loginRoutePath),
),
],
@@ -60,6 +69,7 @@ class AppShell extends StatelessWidget {
Navigator.of(context).pop();
context.go(path);
},
pages: pages,
),
),
),
@@ -71,16 +81,18 @@ class AppShell extends StatelessWidget {
}
class _NavigationRail extends StatelessWidget {
const _NavigationRail({required this.currentLocation});
const _NavigationRail({required this.currentLocation, required this.pages});
final String currentLocation;
final List<AppPageDescriptor> pages;
@override
Widget build(BuildContext context) {
final pages = allAppPages;
final selectedIndex = _selectedIndex(currentLocation, pages);
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final themeController = ThemeControllerScope.of(context);
final currentThemeMode = themeController.mode;
return Container(
width: 104,
@@ -151,6 +163,13 @@ class _NavigationRail extends StatelessWidget {
},
),
),
Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 16),
child: _ThemeMenuButton(
mode: currentThemeMode,
onChanged: themeController.update,
),
),
],
),
);
@@ -158,19 +177,37 @@ class _NavigationRail extends StatelessWidget {
}
class _NavigationList extends StatelessWidget {
const _NavigationList({required this.currentLocation, required this.onTap});
const _NavigationList({
required this.currentLocation,
required this.onTap,
required this.pages,
});
final String currentLocation;
final ValueChanged<String> onTap;
final List<AppPageDescriptor> pages;
@override
Widget build(BuildContext context) {
final pages = allAppPages;
final selectedIndex = _selectedIndex(currentLocation, pages);
final themeController = ThemeControllerScope.of(context);
return ListView.builder(
itemCount: pages.length,
itemCount: pages.length + 1,
itemBuilder: (context, index) {
if (index == pages.length) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
child: _ThemeMenuButton(
mode: themeController.mode,
onChanged: (mode) {
themeController.update(mode);
Navigator.of(context).maybePop();
},
),
);
}
final page = pages[index];
final selected = index == selectedIndex;
return ListTile(
@@ -190,6 +227,77 @@ class _NavigationList extends StatelessWidget {
}
}
class _ThemeMenuButton extends StatelessWidget {
const _ThemeMenuButton({required this.mode, required this.onChanged});
final ThemeMode mode;
final ValueChanged<ThemeMode> onChanged;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final label = _label(mode);
final icon = _icon(mode);
return PopupMenuButton<ThemeMode>(
tooltip: '테마 변경',
onSelected: onChanged,
itemBuilder: (context) => ThemeMode.values
.map(
(value) => PopupMenuItem<ThemeMode>(
value: value,
child: Row(
children: [
Icon(_icon(value), size: 18),
const SizedBox(width: 8),
Text(_label(value)),
],
),
),
)
.toList(),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(color: colorScheme.outlineVariant),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 18),
const SizedBox(width: 8),
Text('테마 · $label', style: theme.textTheme.labelSmall),
],
),
),
);
}
static String _label(ThemeMode mode) {
switch (mode) {
case ThemeMode.system:
return '시스템';
case ThemeMode.light:
return '라이트';
case ThemeMode.dark:
return '다크';
}
}
static IconData _icon(ThemeMode mode) {
switch (mode) {
case ThemeMode.system:
return lucide.LucideIcons.monitorCog;
case ThemeMode.light:
return lucide.LucideIcons.sun;
case ThemeMode.dark:
return lucide.LucideIcons.moon;
}
}
}
int _selectedIndex(String location, List<AppPageDescriptor> pages) {
final normalized = location.toLowerCase();
final exact = pages.indexWhere(

View File

@@ -1,11 +1,21 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide;
import 'package:shadcn_ui/shadcn_ui.dart';
class EmptyState extends StatelessWidget {
const EmptyState({super.key, required this.message, this.icon});
/// 데이터가 없을 때 사용자에게 명확한 안내를 제공하는 공통 위젯.
class SuperportEmptyState extends StatelessWidget {
const SuperportEmptyState({
super.key,
required this.title,
this.description,
this.icon = lucide.LucideIcons.inbox,
this.action,
});
final String message;
final String title;
final String? description;
final IconData? icon;
final Widget? action;
@override
Widget build(BuildContext context) {
@@ -16,8 +26,17 @@ class EmptyState extends StatelessWidget {
children: [
if (icon != null)
Icon(icon, size: 48, color: theme.colorScheme.mutedForeground),
if (icon != null) const SizedBox(height: 16),
Text(message, style: theme.textTheme.muted),
const SizedBox(height: 16),
Text(title, style: theme.textTheme.h4),
if (description != null) ...[
const SizedBox(height: 8),
Text(
description!,
style: theme.textTheme.muted,
textAlign: TextAlign.center,
),
],
if (action != null) ...[const SizedBox(height: 20), action!],
],
),
);

View File

@@ -0,0 +1,131 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
/// Superport 전역에서 사용하는 토스트/스낵바 헬퍼.
class SuperportToast {
SuperportToast._();
static void success(BuildContext context, String message) {
_show(context, message, _ToastVariant.success);
}
static void info(BuildContext context, String message) {
_show(context, message, _ToastVariant.info);
}
static void warning(BuildContext context, String message) {
_show(context, message, _ToastVariant.warning);
}
static void error(BuildContext context, String message) {
_show(context, message, _ToastVariant.error);
}
static void _show(
BuildContext context,
String message,
_ToastVariant variant,
) {
final theme = ShadTheme.of(context);
final (Color background, Color foreground) = switch (variant) {
_ToastVariant.success => (
theme.colorScheme.primary,
theme.colorScheme.primaryForeground,
),
_ToastVariant.info => (
theme.colorScheme.accent,
theme.colorScheme.accentForeground,
),
_ToastVariant.warning => (
theme.colorScheme.secondary,
theme.colorScheme.secondaryForeground,
),
_ToastVariant.error => (
theme.colorScheme.destructive,
theme.colorScheme.destructiveForeground,
),
};
final messenger = ScaffoldMessenger.of(context);
messenger
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(
content: Text(
message,
style: theme.textTheme.small.copyWith(
color: foreground,
fontWeight: FontWeight.w600,
),
),
backgroundColor: background,
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 3),
),
);
}
}
enum _ToastVariant { success, info, warning, error }
/// 기본 골격을 표현하는 스켈레톤 블록.
class SuperportSkeleton extends StatelessWidget {
const SuperportSkeleton({
super.key,
this.width,
this.height = 16,
this.borderRadius = const BorderRadius.all(Radius.circular(8)),
});
final double? width;
final double height;
final BorderRadius borderRadius;
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
return AnimatedContainer(
duration: const Duration(milliseconds: 600),
decoration: BoxDecoration(
color: theme.colorScheme.muted,
borderRadius: borderRadius,
),
width: width,
height: height,
);
}
}
/// 리스트 데이터를 대체하는 반복 스켈레톤 레이아웃.
class SuperportSkeletonList extends StatelessWidget {
const SuperportSkeletonList({
super.key,
this.itemCount = 6,
this.height = 56,
this.gap = 12,
this.padding = const EdgeInsets.all(16),
});
final int itemCount;
final double height;
final double gap;
final EdgeInsetsGeometry padding;
@override
Widget build(BuildContext context) {
return Padding(
padding: padding,
child: Column(
children: [
for (var i = 0; i < itemCount; i++) ...[
SuperportSkeleton(
height: height,
borderRadius: BorderRadius.circular(10),
),
if (i != itemCount - 1) SizedBox(height: gap),
],
],
),
);
}
}

View File

@@ -3,23 +3,162 @@ import 'package:shadcn_ui/shadcn_ui.dart';
/// 검색/필터 영역을 위한 공통 래퍼.
class FilterBar extends StatelessWidget {
const FilterBar({super.key, required this.children});
const FilterBar({
super.key,
required this.children,
this.title = '검색 및 필터',
this.actions,
this.spacing = 16,
this.runSpacing = 16,
this.actionConfig,
this.leading,
});
final List<Widget> children;
final String? title;
final List<Widget>? actions;
final double spacing;
final double runSpacing;
final FilterBarActionConfig? actionConfig;
final Widget? leading;
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
final computedActions = _resolveActions(context);
final hasHeading =
(title != null && title!.isNotEmpty) || computedActions.isNotEmpty;
return ShadCard(
title: Text('검색 및 필터', style: theme.textTheme.h3),
child: Align(
alignment: Alignment.centerLeft,
child: Wrap(
spacing: 16,
runSpacing: 16,
children: children,
),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (hasHeading)
Padding(
padding: const EdgeInsets.only(bottom: 20),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (leading != null) ...[
leading!,
const SizedBox(width: 12),
],
if (title != null && title!.isNotEmpty)
Text(title!, style: theme.textTheme.h3),
],
),
),
if (computedActions.isNotEmpty)
Wrap(
spacing: 8,
runSpacing: 8,
alignment: WrapAlignment.end,
children: computedActions,
),
],
),
),
Wrap(spacing: spacing, runSpacing: runSpacing, children: children),
],
),
);
}
List<Widget> _resolveActions(BuildContext context) {
final items = <Widget>[];
final config = actionConfig;
if (config != null) {
final badge = _buildStatusBadge(context, config);
if (badge != null) {
items.add(badge);
}
items.add(
ShadButton(
key: config.applyKey,
onPressed: config.canApply ? config.onApply : null,
child: Text(config.applyLabel),
),
);
if (config.shouldShowReset) {
items.add(
ShadButton.ghost(
key: config.resetKey,
onPressed: config.canReset ? config.onReset : null,
child: Text(config.resetLabel),
),
);
}
}
if (actions != null && actions!.isNotEmpty) {
items.addAll(actions!);
}
return items;
}
Widget? _buildStatusBadge(
BuildContext context,
FilterBarActionConfig config,
) {
final theme = ShadTheme.of(context);
if (config.hasPendingChanges) {
return ShadBadge(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Text('미적용 변경', style: theme.textTheme.small),
),
);
}
if (config.hasActiveFilters) {
return ShadBadge.outline(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Text('필터 적용됨', style: theme.textTheme.small),
),
);
}
return null;
}
}
/// 필터 적용/초기화 버튼 구성을 위한 상태 객체.
class FilterBarActionConfig {
const FilterBarActionConfig({
required this.onApply,
required this.onReset,
this.hasPendingChanges = false,
this.hasActiveFilters = false,
this.applyLabel = '검색 적용',
this.resetLabel = '초기화',
this.applyEnabled,
this.resetEnabled,
this.showReset,
this.applyKey,
this.resetKey,
});
final VoidCallback onApply;
final VoidCallback onReset;
final bool hasPendingChanges;
final bool hasActiveFilters;
final String applyLabel;
final String resetLabel;
final bool? applyEnabled;
final bool? resetEnabled;
final bool? showReset;
final Key? applyKey;
final Key? resetKey;
bool get canApply => applyEnabled ?? hasPendingChanges;
bool get shouldShowReset =>
showReset ?? (hasActiveFilters || hasPendingChanges);
bool get canReset => resetEnabled ?? shouldShowReset;
}

View File

@@ -0,0 +1,176 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
const double _kFieldSpacing = 8;
const double _kFieldCaptionSpacing = 6;
/// 폼 필드 라벨과 본문을 일관되게 배치하기 위한 위젯.
class SuperportFormField extends StatelessWidget {
const SuperportFormField({
super.key,
required this.label,
required this.child,
this.required = false,
this.caption,
this.errorText,
this.trailing,
this.spacing = _kFieldSpacing,
});
final String label;
final Widget child;
final bool required;
final String? caption;
final String? errorText;
final Widget? trailing;
final double spacing;
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
final captionStyle = theme.textTheme.muted.copyWith(fontSize: 12);
final errorStyle = theme.textTheme.small.copyWith(
fontSize: 12,
color: theme.colorScheme.destructive,
);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: _FieldLabel(label: label, required: required),
),
if (trailing != null) trailing!,
],
),
SizedBox(height: spacing),
child,
if (errorText != null && errorText!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: _kFieldCaptionSpacing),
child: Text(errorText!, style: errorStyle),
)
else if (caption != null && caption!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: _kFieldCaptionSpacing),
child: Text(caption!, style: captionStyle),
),
],
);
}
}
/// `ShadInput`을 Superport 스타일에 맞게 설정한 텍스트 필드.
class SuperportTextInput extends StatelessWidget {
const SuperportTextInput({
super.key,
this.controller,
this.placeholder,
this.onChanged,
this.onSubmitted,
this.keyboardType,
this.enabled = true,
this.readOnly = false,
this.maxLines = 1,
this.leading,
this.trailing,
});
final TextEditingController? controller;
final Widget? placeholder;
final ValueChanged<String>? onChanged;
final ValueChanged<String>? onSubmitted;
final TextInputType? keyboardType;
final bool enabled;
final bool readOnly;
final int maxLines;
final Widget? leading;
final Widget? trailing;
@override
Widget build(BuildContext context) {
return ShadInput(
controller: controller,
placeholder: placeholder,
enabled: enabled,
readOnly: readOnly,
keyboardType: keyboardType,
maxLines: maxLines,
leading: leading,
trailing: trailing,
onChanged: onChanged,
onSubmitted: onSubmitted,
);
}
}
/// `ShadSwitch`를 라벨과 함께 사용하기 위한 헬퍼.
class SuperportSwitchField extends StatelessWidget {
const SuperportSwitchField({
super.key,
required this.value,
required this.onChanged,
this.label,
this.caption,
});
final bool value;
final ValueChanged<bool> onChanged;
final String? label;
final String? caption;
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (label != null) Text(label!, style: theme.textTheme.small),
const SizedBox(height: 8),
ShadSwitch(value: value, onChanged: onChanged),
if (caption != null && caption!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: _kFieldCaptionSpacing),
child: Text(caption!, style: theme.textTheme.muted),
),
],
);
}
}
class _FieldLabel extends StatelessWidget {
const _FieldLabel({required this.label, required this.required});
final String label;
final bool required;
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
final textStyle = theme.textTheme.small.copyWith(
fontWeight: FontWeight.w600,
);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(label, style: textStyle),
if (required)
Padding(
padding: const EdgeInsets.only(left: 4),
child: Text(
'*',
style: theme.textTheme.small.copyWith(
color: theme.colorScheme.destructive,
fontWeight: FontWeight.w600,
),
),
),
],
);
}
}

View File

@@ -25,10 +25,7 @@ class PageHeader extends StatelessWidget {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (leading != null) ...[
leading!,
const SizedBox(width: 16),
],
if (leading != null) ...[leading!, const SizedBox(width: 16)],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -42,16 +39,9 @@ class PageHeader extends StatelessWidget {
),
),
if (actions != null && actions!.isNotEmpty) ...[
Wrap(
spacing: 12,
runSpacing: 12,
children: actions!,
),
],
if (trailing != null) ...[
const SizedBox(width: 16),
trailing!,
Wrap(spacing: 12, runSpacing: 12, children: actions!),
],
if (trailing != null) ...[const SizedBox(width: 16), trailing!],
],
);
}

View File

@@ -1,6 +1,98 @@
import 'package:flutter/widgets.dart';
const double desktopBreakpoint = 1200;
const double tabletBreakpoint = 960;
enum DeviceBreakpoint { mobile, tablet, desktop }
DeviceBreakpoint breakpointForWidth(double width) {
if (width >= desktopBreakpoint) {
return DeviceBreakpoint.desktop;
}
if (width >= tabletBreakpoint) {
return DeviceBreakpoint.tablet;
}
return DeviceBreakpoint.mobile;
}
bool isDesktop(double width) => width >= desktopBreakpoint;
bool isTablet(double width) => width >= tabletBreakpoint && width < desktopBreakpoint;
bool isTablet(double width) =>
width >= tabletBreakpoint && width < desktopBreakpoint;
bool isMobile(double width) => width < tabletBreakpoint;
bool isDesktopContext(BuildContext context) =>
isDesktop(MediaQuery.of(context).size.width);
bool isTabletContext(BuildContext context) =>
isTablet(MediaQuery.of(context).size.width);
bool isMobileContext(BuildContext context) =>
isMobile(MediaQuery.of(context).size.width);
class ResponsiveBreakpoints {
ResponsiveBreakpoints._(this.width) : breakpoint = breakpointForWidth(width);
final double width;
final DeviceBreakpoint breakpoint;
bool get isMobile => breakpoint == DeviceBreakpoint.mobile;
bool get isTablet => breakpoint == DeviceBreakpoint.tablet;
bool get isDesktop => breakpoint == DeviceBreakpoint.desktop;
static ResponsiveBreakpoints of(BuildContext context) {
final size = MediaQuery.of(context).size;
return ResponsiveBreakpoints._(size.width);
}
}
class ResponsiveLayoutBuilder extends StatelessWidget {
const ResponsiveLayoutBuilder({
super.key,
required this.mobile,
this.tablet,
required this.desktop,
});
final WidgetBuilder mobile;
final WidgetBuilder? tablet;
final WidgetBuilder desktop;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final breakpoint = breakpointForWidth(constraints.maxWidth);
switch (breakpoint) {
case DeviceBreakpoint.mobile:
return mobile(context);
case DeviceBreakpoint.tablet:
final tabletBuilder = tablet ?? desktop;
return tabletBuilder(context);
case DeviceBreakpoint.desktop:
return desktop(context);
}
},
);
}
}
class ResponsiveVisibility extends StatelessWidget {
const ResponsiveVisibility({
super.key,
required this.child,
this.replacement = const SizedBox.shrink(),
this.visibleOn = const {
DeviceBreakpoint.mobile,
DeviceBreakpoint.tablet,
DeviceBreakpoint.desktop,
},
});
final Widget child;
final Widget replacement;
final Set<DeviceBreakpoint> visibleOn;
@override
Widget build(BuildContext context) {
final breakpoint = ResponsiveBreakpoints.of(context).breakpoint;
return visibleOn.contains(breakpoint) ? child : replacement;
}
}

View File

@@ -0,0 +1,133 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart' as intl;
import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide;
import 'package:shadcn_ui/shadcn_ui.dart';
/// 단일 날짜 선택을 위한 공통 버튼 위젯.
class SuperportDatePickerButton extends StatelessWidget {
const SuperportDatePickerButton({
super.key,
required this.value,
required this.onChanged,
this.firstDate,
this.lastDate,
this.dateFormat,
this.placeholder = '날짜 선택',
this.enabled = true,
this.initialDate,
});
final DateTime? value;
final ValueChanged<DateTime> onChanged;
final DateTime? firstDate;
final DateTime? lastDate;
final intl.DateFormat? dateFormat;
final String placeholder;
final bool enabled;
final DateTime? initialDate;
@override
Widget build(BuildContext context) {
final format = dateFormat ?? intl.DateFormat('yyyy-MM-dd');
final displayText = value != null ? format.format(value!) : placeholder;
return ShadButton.outline(
onPressed: !enabled
? null
: () async {
final now = DateTime.now();
final baseFirst = firstDate ?? DateTime(now.year - 10);
final baseLast = lastDate ?? DateTime(now.year + 5);
final seed = value ?? initialDate ?? now;
final adjustedInitial = seed.clamp(baseFirst, baseLast);
final picked = await showDatePicker(
context: context,
initialDate: adjustedInitial,
firstDate: baseFirst,
lastDate: baseLast,
);
if (picked != null) {
onChanged(picked);
}
},
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
mainAxisSize: MainAxisSize.min,
children: [
Text(displayText),
const SizedBox(width: 8),
const Icon(lucide.LucideIcons.calendar, size: 16),
],
),
);
}
}
/// 날짜 범위 선택을 위한 공통 버튼 위젯.
class SuperportDateRangePickerButton extends StatelessWidget {
const SuperportDateRangePickerButton({
super.key,
required this.value,
required this.onChanged,
this.firstDate,
this.lastDate,
this.dateFormat,
this.placeholder = '기간 선택',
this.enabled = true,
this.initialDateRange,
});
final DateTimeRange? value;
final ValueChanged<DateTimeRange?> onChanged;
final DateTime? firstDate;
final DateTime? lastDate;
final intl.DateFormat? dateFormat;
final String placeholder;
final bool enabled;
final DateTimeRange? initialDateRange;
@override
Widget build(BuildContext context) {
final format = dateFormat ?? intl.DateFormat('yyyy-MM-dd');
final label = value == null
? placeholder
: '${format.format(value!.start)} ~ ${format.format(value!.end)}';
return ShadButton.outline(
onPressed: !enabled
? null
: () async {
final now = DateTime.now();
final baseFirst = firstDate ?? DateTime(now.year - 10);
final baseLast = lastDate ?? DateTime(now.year + 5);
final initialRange = value ?? initialDateRange;
final currentDate = initialRange?.start ?? now;
final picked = await showDateRangePicker(
context: context,
firstDate: baseFirst,
lastDate: baseLast,
initialDateRange: initialRange,
currentDate: currentDate.clamp(baseFirst, baseLast),
);
if (picked != null) {
onChanged(picked);
}
},
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
const Icon(lucide.LucideIcons.calendar, size: 16),
const SizedBox(width: 8),
Flexible(child: Text(label, overflow: TextOverflow.ellipsis)),
],
),
);
}
}
extension _ClampDate on DateTime {
DateTime clamp(DateTime min, DateTime max) {
if (isBefore(min)) return min;
if (isAfter(max)) return max;
return this;
}
}

View File

@@ -1,40 +1,327 @@
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 showDialog<T>(
return SuperportDialog.show<T>(
context: context,
barrierDismissible: barrierDismissible,
builder: (dialogContext) {
final theme = ShadTheme.of(dialogContext);
return Dialog(
insetPadding: const EdgeInsets.all(24),
clipBehavior: Clip.antiAlias,
child: ShadCard(
title: Text(title, style: theme.textTheme.h3),
description: description == null
? null
: Text(description, style: theme.textTheme.muted),
footer: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: actions ?? <Widget>[
ShadButton.ghost(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('닫기'),
),
],
),
child: body,
),
);
},
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,
),
);
}

View File

@@ -1,3 +1,5 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
@@ -5,54 +7,107 @@ import 'package:shadcn_ui/shadcn_ui.dart';
class SuperportTable extends StatelessWidget {
const SuperportTable({
super.key,
required this.columns,
required this.rows,
required List<Widget> columns,
required List<List<Widget>> rows,
this.columnSpanExtent,
this.rowHeight = 56,
this.maxHeight,
this.onRowTap,
this.emptyLabel = '데이터가 없습니다.',
});
}) : _columns = columns,
_rows = rows,
_headerCells = null,
_rowCells = null;
final List<Widget> columns;
final List<List<Widget>> rows;
const SuperportTable.fromCells({
super.key,
required List<ShadTableCell> header,
required List<List<ShadTableCell>> rows,
this.columnSpanExtent,
this.rowHeight = 56,
this.maxHeight,
this.onRowTap,
this.emptyLabel = '데이터가 없습니다.',
}) : _columns = null,
_rows = null,
_headerCells = header,
_rowCells = rows;
final List<Widget>? _columns;
final List<List<Widget>>? _rows;
final List<ShadTableCell>? _headerCells;
final List<List<ShadTableCell>>? _rowCells;
final TableSpanExtent? Function(int index)? columnSpanExtent;
final double rowHeight;
final double? maxHeight;
final void Function(int index)? onRowTap;
final String emptyLabel;
@override
Widget build(BuildContext context) {
if (rows.isEmpty) {
final theme = ShadTheme.of(context);
return Padding(
padding: const EdgeInsets.all(32),
child: Center(
child: Text(emptyLabel, style: theme.textTheme.muted),
),
);
}
late final List<ShadTableCell> headerCells;
late final List<List<ShadTableCell>> tableRows;
final tableRows = [
for (final row in rows)
row
.map(
(cell) => cell is ShadTableCell ? cell : ShadTableCell(child: cell),
)
.toList(),
];
return ShadTable.list(
header: columns
if (_rowCells case final rows?) {
if (rows.isEmpty) {
final theme = ShadTheme.of(context);
return Padding(
padding: const EdgeInsets.all(32),
child: Center(child: Text(emptyLabel, style: theme.textTheme.muted)),
);
}
final header = _headerCells;
if (header == null) {
throw StateError('header cells must not be null when using fromCells');
}
headerCells = header;
tableRows = rows;
} else {
final rows = _rows;
if (rows == null || rows.isEmpty) {
final theme = ShadTheme.of(context);
return Padding(
padding: const EdgeInsets.all(32),
child: Center(child: Text(emptyLabel, style: theme.textTheme.muted)),
);
}
headerCells = _columns!
.map(
(cell) => cell is ShadTableCell
? cell
: ShadTableCell.header(child: cell),
)
.toList(),
columnSpanExtent: columnSpanExtent,
rowSpanExtent: (_) => FixedTableSpanExtent(rowHeight),
onRowTap: onRowTap,
children: tableRows,
.toList();
tableRows = [
for (final row in rows)
row
.map(
(cell) =>
cell is ShadTableCell ? cell : ShadTableCell(child: cell),
)
.toList(),
];
}
final estimatedHeight = (tableRows.length + 1) * rowHeight;
final minHeight = rowHeight * 2;
final effectiveHeight = math.max(
minHeight,
maxHeight == null
? estimatedHeight
: math.min(estimatedHeight, maxHeight!),
);
return SizedBox(
height: effectiveHeight,
child: ShadTable.list(
header: headerCells,
columnSpanExtent: columnSpanExtent,
rowSpanExtent: (_) => FixedTableSpanExtent(rowHeight),
onRowTap: onRowTap,
primary: false,
children: tableRows,
),
);
}
}