사용하지 않는 파일 정리 전 백업 (Phase 10 완료 후 상태)

This commit is contained in:
JiWoong Sul
2025-08-29 15:11:59 +09:00
parent a740ff10c8
commit d916b281a7
333 changed files with 53617 additions and 22574 deletions

View File

@@ -0,0 +1,368 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
class SuperportShadDatePicker extends StatefulWidget {
final String label;
final DateTime? value;
final ValueChanged<DateTime?>? onChanged;
final DateTime? firstDate;
final DateTime? lastDate;
final String dateFormat;
final String? placeholder;
final bool enabled;
final bool required;
final String? errorText;
final String? helperText;
final bool allowClear;
final DatePickerMode mode;
const SuperportShadDatePicker({
super.key,
required this.label,
this.value,
this.onChanged,
this.firstDate,
this.lastDate,
this.dateFormat = 'yyyy-MM-dd',
this.placeholder,
this.enabled = true,
this.required = false,
this.errorText,
this.helperText,
this.allowClear = true,
this.mode = DatePickerMode.day,
});
@override
State<SuperportShadDatePicker> createState() => _SuperportShadDatePickerState();
}
class _SuperportShadDatePickerState extends State<SuperportShadDatePicker> {
late TextEditingController _controller;
final FocusNode _focusNode = FocusNode();
@override
void initState() {
super.initState();
_controller = TextEditingController(
text: widget.value != null
? DateFormat(widget.dateFormat).format(widget.value!)
: '',
);
}
@override
void didUpdateWidget(SuperportShadDatePicker oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.value != widget.value) {
_controller.text = widget.value != null
? DateFormat(widget.dateFormat).format(widget.value!)
: '';
}
}
@override
void dispose() {
_controller.dispose();
_focusNode.dispose();
super.dispose();
}
Future<void> _selectDate() async {
final theme = ShadTheme.of(context);
final DateTime? picked = await showDatePicker(
context: context,
initialDate: widget.value ?? DateTime.now(),
firstDate: widget.firstDate ?? DateTime(1900),
lastDate: widget.lastDate ?? DateTime(2100),
initialDatePickerMode: widget.mode,
locale: const Locale('ko', 'KR'),
builder: (context, child) {
return Theme(
data: ThemeData.light().copyWith(
primaryColor: theme.colorScheme.primary,
colorScheme: ColorScheme.light(
primary: theme.colorScheme.primary,
onPrimary: theme.colorScheme.primaryForeground,
surface: theme.colorScheme.card,
onSurface: theme.colorScheme.cardForeground,
),
),
child: child!,
);
},
);
if (picked != null) {
widget.onChanged?.call(picked);
}
}
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
widget.label,
style: theme.textTheme.small.copyWith(
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
if (widget.required)
Text(
' *',
style: theme.textTheme.small.copyWith(
color: theme.colorScheme.destructive,
fontWeight: FontWeight.w600,
),
),
],
),
const SizedBox(height: 8),
InkWell(
onTap: widget.enabled ? _selectDate : null,
borderRadius: BorderRadius.circular(6),
child: IgnorePointer(
child: ShadInput(
controller: _controller,
focusNode: _focusNode,
placeholder: Text(widget.placeholder ?? 'YYYY-MM-DD'),
enabled: widget.enabled,
readOnly: true,
),
),
),
if (widget.errorText != null) ...[
const SizedBox(height: 4),
Text(
widget.errorText!,
style: theme.textTheme.small.copyWith(
color: theme.colorScheme.destructive,
fontSize: 12,
),
),
],
if (widget.helperText != null && widget.errorText == null) ...[
const SizedBox(height: 4),
Text(
widget.helperText!,
style: theme.textTheme.small.copyWith(
color: theme.colorScheme.mutedForeground,
fontSize: 12,
),
),
],
],
);
}
}
class SuperportShadDateRangePicker extends StatefulWidget {
final String label;
final DateTimeRange? value;
final ValueChanged<DateTimeRange?>? onChanged;
final DateTime? firstDate;
final DateTime? lastDate;
final String dateFormat;
final String? placeholder;
final bool enabled;
final bool required;
final String? errorText;
final String? helperText;
final bool allowClear;
const SuperportShadDateRangePicker({
super.key,
required this.label,
this.value,
this.onChanged,
this.firstDate,
this.lastDate,
this.dateFormat = 'yyyy-MM-dd',
this.placeholder,
this.enabled = true,
this.required = false,
this.errorText,
this.helperText,
this.allowClear = true,
});
@override
State<SuperportShadDateRangePicker> createState() => _SuperportShadDateRangePickerState();
}
class _SuperportShadDateRangePickerState extends State<SuperportShadDateRangePicker> {
late TextEditingController _controller;
final FocusNode _focusNode = FocusNode();
@override
void initState() {
super.initState();
_updateControllerText();
}
void _updateControllerText() {
if (widget.value != null) {
final startText = DateFormat(widget.dateFormat).format(widget.value!.start);
final endText = DateFormat(widget.dateFormat).format(widget.value!.end);
_controller = TextEditingController(text: '$startText ~ $endText');
} else {
_controller = TextEditingController();
}
}
@override
void didUpdateWidget(SuperportShadDateRangePicker oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.value != widget.value) {
_updateControllerText();
}
}
@override
void dispose() {
_controller.dispose();
_focusNode.dispose();
super.dispose();
}
Future<void> _selectDateRange() async {
final theme = ShadTheme.of(context);
final DateTimeRange? picked = await showDateRangePicker(
context: context,
initialDateRange: widget.value,
firstDate: widget.firstDate ?? DateTime(1900),
lastDate: widget.lastDate ?? DateTime(2100),
locale: const Locale('ko', 'KR'),
builder: (context, child) {
return Theme(
data: ThemeData.light().copyWith(
primaryColor: theme.colorScheme.primary,
colorScheme: ColorScheme.light(
primary: theme.colorScheme.primary,
onPrimary: theme.colorScheme.primaryForeground,
surface: theme.colorScheme.card,
onSurface: theme.colorScheme.cardForeground,
),
),
child: child!,
);
},
);
if (picked != null) {
widget.onChanged?.call(picked);
}
}
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
widget.label,
style: theme.textTheme.small.copyWith(
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
if (widget.required)
Text(
' *',
style: theme.textTheme.small.copyWith(
color: theme.colorScheme.destructive,
fontWeight: FontWeight.w600,
),
),
],
),
const SizedBox(height: 8),
InkWell(
onTap: widget.enabled ? _selectDateRange : null,
borderRadius: BorderRadius.circular(6),
child: IgnorePointer(
child: ShadInput(
controller: _controller,
focusNode: _focusNode,
placeholder: Text(widget.placeholder ?? 'YYYY-MM-DD ~ YYYY-MM-DD'),
enabled: widget.enabled,
readOnly: true,
),
),
),
if (widget.errorText != null) ...[
const SizedBox(height: 4),
Text(
widget.errorText!,
style: theme.textTheme.small.copyWith(
color: theme.colorScheme.destructive,
fontSize: 12,
),
),
],
if (widget.helperText != null && widget.errorText == null) ...[
const SizedBox(height: 4),
Text(
widget.helperText!,
style: theme.textTheme.small.copyWith(
color: theme.colorScheme.mutedForeground,
fontSize: 12,
),
),
],
],
);
}
}
class KoreanDateTimeFormatter {
static String format(DateTime dateTime, {String pattern = 'yyyy년 MM월 dd일 (E)'}) {
final formatter = DateFormat(pattern, 'ko_KR');
return formatter.format(dateTime);
}
static String formatRange(DateTimeRange range, {String pattern = 'yyyy.MM.dd'}) {
final formatter = DateFormat(pattern, 'ko_KR');
return '${formatter.format(range.start)} ~ ${formatter.format(range.end)}';
}
static String formatRelative(DateTime dateTime) {
final now = DateTime.now();
final difference = now.difference(dateTime);
if (difference.inDays == 0) {
if (difference.inHours == 0) {
if (difference.inMinutes == 0) {
return '방금 전';
}
return '${difference.inMinutes}분 전';
}
return '${difference.inHours}시간 전';
} else if (difference.inDays == 1) {
return '어제';
} else if (difference.inDays == 2) {
return '그저께';
} else if (difference.inDays < 7) {
return '${difference.inDays}일 전';
} else if (difference.inDays < 30) {
return '${(difference.inDays / 7).round()}주 전';
} else if (difference.inDays < 365) {
return '${(difference.inDays / 30).round()}개월 전';
} else {
return '${(difference.inDays / 365).round()}년 전';
}
}
}

View File

@@ -0,0 +1,398 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
enum DialogType {
info,
warning,
error,
success,
confirm,
custom,
}
class SuperportShadDialog extends StatelessWidget {
final String title;
final String? description;
final Widget? content;
final List<Widget>? actions;
final DialogType type;
final bool dismissible;
final double? width;
final double? maxHeight;
final VoidCallback? onClose;
final bool showCloseButton;
final bool loading;
final String? loadingMessage;
const SuperportShadDialog({
super.key,
required this.title,
this.description,
this.content,
this.actions,
this.type = DialogType.custom,
this.dismissible = true,
this.width,
this.maxHeight,
this.onClose,
this.showCloseButton = true,
this.loading = false,
this.loadingMessage,
});
static Future<T?> show<T>({
required BuildContext context,
required String title,
String? description,
Widget? content,
List<Widget>? actions,
DialogType type = DialogType.custom,
bool dismissible = true,
double? width,
double? maxHeight,
bool showCloseButton = true,
}) {
return showDialog<T>(
context: context,
barrierDismissible: dismissible,
builder: (context) => SuperportShadDialog(
title: title,
description: description,
content: content,
actions: actions,
type: type,
dismissible: dismissible,
width: width,
maxHeight: maxHeight,
showCloseButton: showCloseButton,
),
);
}
static Future<bool?> confirm({
required BuildContext context,
required String title,
required String message,
String confirmText = '확인',
String cancelText = '취소',
DialogType type = DialogType.confirm,
}) {
return showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (context) => SuperportShadDialog(
title: title,
description: message,
type: type,
dismissible: false,
showCloseButton: false,
actions: [
ShadButton.outline(
onPressed: () => Navigator.of(context).pop(false),
child: Text(cancelText),
),
ShadButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(confirmText),
),
],
),
);
}
static Future<void> alert({
required BuildContext context,
required String title,
required String message,
String confirmText = '확인',
DialogType type = DialogType.info,
}) {
return showDialog<void>(
context: context,
barrierDismissible: false,
builder: (context) => SuperportShadDialog(
title: title,
description: message,
type: type,
dismissible: false,
showCloseButton: false,
actions: [
ShadButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(confirmText),
),
],
),
);
}
static Future<T?> form<T>({
required BuildContext context,
required String title,
required Widget form,
required Future<T?> Function() onSubmit,
String submitText = '저장',
String cancelText = '취소',
double? width,
double? maxHeight,
}) async {
bool isLoading = false;
String? errorMessage;
return await showDialog<T>(
context: context,
barrierDismissible: false,
builder: (dialogContext) => StatefulBuilder(
builder: (context, setState) {
return SuperportShadDialog(
title: title,
width: width ?? 500,
maxHeight: maxHeight,
dismissible: false,
showCloseButton: !isLoading,
loading: isLoading,
loadingMessage: '처리 중...',
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (errorMessage != null) ...[
ShadAlert.destructive(
title: const Text('오류'),
description: Text(errorMessage!),
),
const SizedBox(height: 16),
],
form,
],
),
actions: isLoading
? null
: [
ShadButton.outline(
onPressed: () => Navigator.of(dialogContext).pop(),
child: Text(cancelText),
),
ShadButton(
onPressed: () async {
setState(() {
isLoading = true;
errorMessage = null;
});
try {
final result = await onSubmit();
if (dialogContext.mounted) {
Navigator.of(dialogContext).pop(result);
}
} catch (e) {
setState(() {
isLoading = false;
errorMessage = e.toString();
});
}
},
child: Text(submitText),
),
],
);
},
),
);
}
IconData _getIconForType() {
switch (type) {
case DialogType.info:
return Icons.info_outline;
case DialogType.warning:
return Icons.warning_amber_outlined;
case DialogType.error:
return Icons.error_outline;
case DialogType.success:
return Icons.check_circle_outline;
case DialogType.confirm:
return Icons.help_outline;
case DialogType.custom:
default:
return Icons.message_outlined;
}
}
Color _getColorForType(ShadColorScheme colorScheme) {
switch (type) {
case DialogType.info:
return colorScheme.primary;
case DialogType.warning:
return const Color(0xFFFFC107);
case DialogType.error:
return colorScheme.destructive;
case DialogType.success:
return const Color(0xFF2E8B57);
case DialogType.confirm:
return colorScheme.primary;
case DialogType.custom:
default:
return colorScheme.foreground;
}
}
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
final typeColor = _getColorForType(theme.colorScheme);
return Dialog(
backgroundColor: Colors.transparent,
child: Container(
width: width ?? 480,
constraints: BoxConstraints(
maxWidth: width ?? 480,
maxHeight: maxHeight ?? MediaQuery.of(context).size.height * 0.8,
),
decoration: BoxDecoration(
color: theme.colorScheme.background,
borderRadius: theme.radius,
border: Border.all(
color: theme.colorScheme.border,
width: 1,
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: theme.colorScheme.border,
width: 1,
),
),
),
child: Row(
children: [
if (type != DialogType.custom) ...[
Icon(
_getIconForType(),
color: typeColor,
size: 24,
),
const SizedBox(width: 12),
],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: theme.textTheme.h4.copyWith(
fontWeight: FontWeight.w600,
),
),
if (description != null && content == null) ...[
const SizedBox(height: 8),
Text(
description!,
style: theme.textTheme.p.copyWith(
color: theme.colorScheme.mutedForeground,
),
),
],
],
),
),
if (showCloseButton && !loading) ...[
const SizedBox(width: 12),
IconButton(
icon: const Icon(Icons.close, size: 20),
onPressed: onClose ?? () => Navigator.of(context).pop(),
style: IconButton.styleFrom(
foregroundColor: theme.colorScheme.mutedForeground,
),
),
],
],
),
),
if (loading) ...[
Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
if (loadingMessage != null) ...[
const SizedBox(height: 16),
Text(
loadingMessage!,
style: theme.textTheme.muted,
),
],
],
),
),
] else ...[
if (content != null) ...[
Flexible(
child: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: content!,
),
),
],
if (actions != null && actions!.isNotEmpty) ...[
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
border: Border(
top: BorderSide(
color: theme.colorScheme.border,
width: 1,
),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
for (int i = 0; i < actions!.length; i++) ...[
if (i > 0) const SizedBox(width: 8),
actions![i],
],
],
),
),
],
],
],
),
),
);
}
}
class FormDialog extends StatefulWidget {
final Widget child;
final GlobalKey<FormState> formKey;
const FormDialog({
super.key,
required this.child,
required this.formKey,
});
@override
State<FormDialog> createState() => _FormDialogState();
}
class _FormDialogState extends State<FormDialog> {
@override
Widget build(BuildContext context) {
return Form(
key: widget.formKey,
child: widget.child,
);
}
}

View File

@@ -0,0 +1,314 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
class SuperportShadSelect<T> extends StatefulWidget {
final String label;
final String? placeholder;
final List<T> items;
final T? value;
final String Function(T) itemLabel;
final ValueChanged<T?>? onChanged;
final bool enabled;
final bool searchable;
final String? errorText;
final String? helperText;
final Widget? prefixIcon;
final VoidCallback? onClear;
final Future<List<T>> Function(String)? onSearch;
final bool required;
const SuperportShadSelect({
super.key,
required this.label,
required this.items,
required this.itemLabel,
this.placeholder,
this.value,
this.onChanged,
this.enabled = true,
this.searchable = false,
this.errorText,
this.helperText,
this.prefixIcon,
this.onClear,
this.onSearch,
this.required = false,
});
@override
State<SuperportShadSelect<T>> createState() => _SuperportShadSelectState<T>();
}
class _SuperportShadSelectState<T> extends State<SuperportShadSelect<T>> {
final TextEditingController _searchController = TextEditingController();
List<T> _filteredItems = [];
bool _isSearching = false;
@override
void initState() {
super.initState();
_filteredItems = widget.items;
}
@override
void didUpdateWidget(SuperportShadSelect<T> oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.items != widget.items) {
_filteredItems = widget.items;
}
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
void _handleSearch(String query) async {
if (query.isEmpty) {
setState(() {
_filteredItems = widget.items;
_isSearching = false;
});
return;
}
setState(() {
_isSearching = true;
});
if (widget.onSearch != null) {
final results = await widget.onSearch!(query);
setState(() {
_filteredItems = results;
_isSearching = false;
});
} else {
setState(() {
_filteredItems = widget.items.where((item) {
return widget.itemLabel(item)
.toLowerCase()
.contains(query.toLowerCase());
}).toList();
_isSearching = false;
});
}
}
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
widget.label,
style: theme.textTheme.small.copyWith(
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
if (widget.required)
Text(
' *',
style: theme.textTheme.small.copyWith(
color: theme.colorScheme.destructive,
fontWeight: FontWeight.w600,
),
),
],
),
const SizedBox(height: 8),
ShadSelect<T>(
placeholder: widget.placeholder != null
? Text(widget.placeholder!)
: const Text('선택하세요'),
selectedOptionBuilder: (context, value) {
if (value == null) return const Text('선택하세요');
return Text(widget.itemLabel(value));
},
enabled: widget.enabled,
options: _filteredItems.map((item) {
return ShadOption(
value: item,
child: Row(
children: [
if (widget.value == item)
const Padding(
padding: EdgeInsets.only(right: 8),
child: Icon(
Icons.check,
size: 16,
),
),
Expanded(
child: Text(
widget.itemLabel(item),
style: theme.textTheme.small.copyWith(
fontWeight: widget.value == item
? FontWeight.w600
: FontWeight.w400,
),
),
),
],
),
);
}).toList(),
onChanged: widget.onChanged,
),
if (widget.errorText != null) ...[
const SizedBox(height: 4),
Text(
widget.errorText!,
style: theme.textTheme.small.copyWith(
color: theme.colorScheme.destructive,
fontSize: 12,
),
),
],
if (widget.helperText != null && widget.errorText == null) ...[
const SizedBox(height: 4),
Text(
widget.helperText!,
style: theme.textTheme.small.copyWith(
color: theme.colorScheme.mutedForeground,
fontSize: 12,
),
),
],
],
);
}
}
class CascadeSelect<T, U> extends StatefulWidget {
final String parentLabel;
final String childLabel;
final List<T> parentItems;
final String Function(T) parentItemLabel;
final Future<List<U>> Function(T) getChildItems;
final String Function(U) childItemLabel;
final T? parentValue;
final U? childValue;
final ValueChanged<T?>? onParentChanged;
final ValueChanged<U?>? onChildChanged;
final bool enabled;
final bool searchable;
final bool required;
const CascadeSelect({
super.key,
required this.parentLabel,
required this.childLabel,
required this.parentItems,
required this.parentItemLabel,
required this.getChildItems,
required this.childItemLabel,
this.parentValue,
this.childValue,
this.onParentChanged,
this.onChildChanged,
this.enabled = true,
this.searchable = true,
this.required = false,
});
@override
State<CascadeSelect<T, U>> createState() => _CascadeSelectState<T, U>();
}
class _CascadeSelectState<T, U> extends State<CascadeSelect<T, U>> {
List<U> _childItems = [];
bool _isLoadingChildren = false;
@override
void initState() {
super.initState();
if (widget.parentValue != null) {
_loadChildItems(widget.parentValue as T);
}
}
@override
void didUpdateWidget(CascadeSelect<T, U> oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.parentValue != widget.parentValue &&
widget.parentValue != null) {
_loadChildItems(widget.parentValue as T);
}
}
Future<void> _loadChildItems(T parentValue) async {
setState(() {
_isLoadingChildren = true;
});
try {
final items = await widget.getChildItems(parentValue);
if (mounted) {
setState(() {
_childItems = items;
_isLoadingChildren = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_childItems = [];
_isLoadingChildren = false;
});
}
}
}
@override
Widget build(BuildContext context) {
return Column(
children: [
SuperportShadSelect<T>(
label: widget.parentLabel,
items: widget.parentItems,
itemLabel: widget.parentItemLabel,
value: widget.parentValue,
onChanged: (value) {
widget.onParentChanged?.call(value);
widget.onChildChanged?.call(null);
if (value != null) {
_loadChildItems(value);
} else {
setState(() {
_childItems = [];
});
}
},
enabled: widget.enabled,
searchable: widget.searchable,
required: widget.required,
placeholder: '${widget.parentLabel}을(를) 선택하세요',
),
const SizedBox(height: 16),
SuperportShadSelect<U>(
label: widget.childLabel,
items: _childItems,
itemLabel: widget.childItemLabel,
value: widget.childValue,
onChanged: widget.onChildChanged,
enabled: widget.enabled &&
widget.parentValue != null &&
!_isLoadingChildren,
searchable: widget.searchable,
required: widget.required,
placeholder: _isLoadingChildren
? '로딩 중...'
: widget.parentValue == null
? '먼저 ${widget.parentLabel}을(를) 선택하세요'
: '${widget.childLabel}을(를) 선택하세요',
),
],
);
}
}

View File

@@ -0,0 +1,386 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
class SuperportShadTable<T> extends StatefulWidget {
final List<T> data;
final List<ShadTableColumn<T>> columns;
final Function(T)? onRowTap;
final Function(T)? onRowDoubleTap;
final bool showCheckbox;
final Set<T>? selectedRows;
final Function(Set<T>)? onSelectionChanged;
final bool sortable;
final int rowsPerPage;
final String? emptyMessage;
final Widget? header;
final Widget? footer;
final bool loading;
final bool striped;
final bool hoverable;
final ScrollController? scrollController;
const SuperportShadTable({
super.key,
required this.data,
required this.columns,
this.onRowTap,
this.onRowDoubleTap,
this.showCheckbox = false,
this.selectedRows,
this.onSelectionChanged,
this.sortable = true,
this.rowsPerPage = 10,
this.emptyMessage,
this.header,
this.footer,
this.loading = false,
this.striped = true,
this.hoverable = true,
this.scrollController,
});
@override
State<SuperportShadTable<T>> createState() => _SuperportShadTableState<T>();
}
class _SuperportShadTableState<T> extends State<SuperportShadTable<T>> {
late List<T> _sortedData;
String? _sortColumn;
bool _sortAscending = true;
int _currentPage = 1;
late Set<T> _selectedRows;
final ScrollController _defaultScrollController = ScrollController();
ScrollController get _scrollController =>
widget.scrollController ?? _defaultScrollController;
@override
void initState() {
super.initState();
_sortedData = List.from(widget.data);
_selectedRows = widget.selectedRows ?? {};
}
@override
void didUpdateWidget(SuperportShadTable<T> oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.data != widget.data) {
_sortedData = List.from(widget.data);
_applySort();
}
if (oldWidget.selectedRows != widget.selectedRows) {
_selectedRows = widget.selectedRows ?? {};
}
}
@override
void dispose() {
_defaultScrollController.dispose();
super.dispose();
}
void _sort(String columnKey) {
if (!widget.sortable) return;
setState(() {
if (_sortColumn == columnKey) {
_sortAscending = !_sortAscending;
} else {
_sortColumn = columnKey;
_sortAscending = true;
}
_applySort();
});
}
void _applySort() {
if (_sortColumn == null) return;
final column = widget.columns.firstWhere(
(col) => col.key == _sortColumn,
orElse: () => widget.columns.first,
);
if (column.sorter != null) {
_sortedData.sort((a, b) {
final result = column.sorter!(a, b);
return _sortAscending ? result : -result;
});
}
}
void _toggleRowSelection(T row) {
setState(() {
if (_selectedRows.contains(row)) {
_selectedRows.remove(row);
} else {
_selectedRows.add(row);
}
widget.onSelectionChanged?.call(_selectedRows);
});
}
void _toggleAllSelection() {
setState(() {
if (_selectedRows.length == _pageData.length) {
_selectedRows.clear();
} else {
_selectedRows = Set.from(_pageData);
}
widget.onSelectionChanged?.call(_selectedRows);
});
}
List<T> get _pageData {
final startIndex = (_currentPage - 1) * widget.rowsPerPage;
final endIndex = startIndex + widget.rowsPerPage;
return _sortedData.sublist(
startIndex,
endIndex.clamp(0, _sortedData.length),
);
}
int get _totalPages {
return (_sortedData.length / widget.rowsPerPage).ceil();
}
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
if (widget.loading) {
return const Center(
child: CircularProgressIndicator(),
);
}
if (_sortedData.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.inbox_outlined,
size: 64,
color: theme.colorScheme.mutedForeground,
),
const SizedBox(height: 16),
Text(
widget.emptyMessage ?? '데이터가 없습니다',
style: theme.textTheme.muted,
),
],
),
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (widget.header != null) ...[
widget.header!,
const SizedBox(height: 16),
],
ShadCard(
padding: EdgeInsets.zero,
child: Column(
children: [
SingleChildScrollView(
controller: _scrollController,
scrollDirection: Axis.horizontal,
child: DataTable(
columns: [
if (widget.showCheckbox)
DataColumn(
label: Checkbox(
value: _selectedRows.length == _pageData.length &&
_pageData.isNotEmpty,
onChanged: (_) => _toggleAllSelection(),
),
),
...widget.columns.map((column) {
return DataColumn(
label: InkWell(
onTap: column.sorter != null && widget.sortable
? () => _sort(column.key)
: null,
child: Row(
children: [
Text(
column.label,
style: theme.textTheme.small.copyWith(
fontWeight: FontWeight.w600,
),
),
if (column.sorter != null && widget.sortable) ...[
const SizedBox(width: 4),
if (_sortColumn == column.key)
Icon(
_sortAscending
? Icons.arrow_upward
: Icons.arrow_downward,
size: 16,
)
else
const Icon(
Icons.unfold_more,
size: 16,
),
],
],
),
),
);
}),
],
rows: _pageData.asMap().entries.map((entry) {
final index = entry.key;
final row = entry.value;
final isSelected = _selectedRows.contains(row);
final isStriped = widget.striped && index.isOdd;
return DataRow(
selected: isSelected,
color: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) {
return theme.colorScheme.accent.withValues(alpha: 0.1);
}
if (widget.hoverable &&
states.contains(WidgetState.hovered)) {
return theme.colorScheme.muted.withValues(alpha: 0.5);
}
if (isStriped) {
return theme.colorScheme.muted.withValues(alpha: 0.3);
}
return null;
}),
onSelectChanged: widget.showCheckbox
? (_) => _toggleRowSelection(row)
: null,
cells: [
if (widget.showCheckbox)
DataCell(
Checkbox(
value: isSelected,
onChanged: (_) => _toggleRowSelection(row),
),
),
...widget.columns.map((column) {
final cellValue = column.accessor(row);
Widget cellWidget;
if (column.render != null) {
cellWidget = column.render!(row, cellValue);
} else if (cellValue is DateTime) {
cellWidget = Text(
DateFormat('yyyy-MM-dd HH:mm').format(cellValue),
style: theme.textTheme.small,
);
} else if (cellValue is num) {
cellWidget = Text(
NumberFormat('#,###').format(cellValue),
style: theme.textTheme.small,
);
} else {
cellWidget = Text(
cellValue?.toString() ?? '-',
style: theme.textTheme.small,
);
}
return DataCell(
cellWidget,
onTap: widget.onRowTap != null
? () => widget.onRowTap!(row)
: null,
onDoubleTap: widget.onRowDoubleTap != null
? () => widget.onRowDoubleTap!(row)
: null,
);
}),
],
);
}).toList(),
),
),
if (_totalPages > 1) ...[
const Divider(height: 1),
Padding(
padding: const EdgeInsets.all(16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${_sortedData.length}개 중 '
'${((_currentPage - 1) * widget.rowsPerPage) + 1}-'
'${((_currentPage - 1) * widget.rowsPerPage) + _pageData.length}',
style: theme.textTheme.muted,
),
Row(
children: [
ShadButton.outline(
size: ShadButtonSize.sm,
enabled: _currentPage > 1,
onPressed: () {
setState(() {
_currentPage--;
});
},
child: const Icon(Icons.chevron_left, size: 16),
),
const SizedBox(width: 8),
Text(
'$_currentPage / $_totalPages',
style: theme.textTheme.small,
),
const SizedBox(width: 8),
ShadButton.outline(
size: ShadButtonSize.sm,
enabled: _currentPage < _totalPages,
onPressed: () {
setState(() {
_currentPage++;
});
},
child: const Icon(Icons.chevron_right, size: 16),
),
],
),
],
),
),
],
],
),
),
if (widget.footer != null) ...[
const SizedBox(height: 16),
widget.footer!,
],
],
);
}
}
class ShadTableColumn<T> {
final String key;
final String label;
final dynamic Function(T) accessor;
final Widget Function(T, dynamic)? render;
final int Function(T, T)? sorter;
final double? width;
final bool sortable;
const ShadTableColumn({
required this.key,
required this.label,
required this.accessor,
this.render,
this.sorter,
this.width,
this.sortable = true,
});
}