전역 구조 리팩터링 및 테스트 확장
This commit is contained in:
@@ -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!],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
131
lib/widgets/components/feedback.dart
Normal file
131
lib/widgets/components/feedback.dart
Normal 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),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
176
lib/widgets/components/form_field.dart
Normal file
176
lib/widgets/components/form_field.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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!],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
133
lib/widgets/components/superport_date_picker.dart
Normal file
133
lib/widgets/components/superport_date_picker.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user