sh-ui-cli 0.15.0 → 0.21.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/sh-ui.mjs +6 -0
- package/data/changelog/versions.json +366 -0
- package/data/registry/flutter/foundation/sh_ui_tokens.dart +385 -0
- package/data/registry/flutter/registry.json +336 -0
- package/data/registry/flutter/widgets/sh_ui_accordion.dart +255 -0
- package/data/registry/flutter/widgets/sh_ui_app_shell.dart +267 -0
- package/data/registry/flutter/widgets/sh_ui_avatar.dart +95 -0
- package/data/registry/flutter/widgets/sh_ui_badge.dart +82 -0
- package/data/registry/flutter/widgets/sh_ui_breadcrumb.dart +107 -0
- package/data/registry/flutter/widgets/sh_ui_button.dart +201 -0
- package/data/registry/flutter/widgets/sh_ui_card.dart +159 -0
- package/data/registry/flutter/widgets/sh_ui_carousel.dart +204 -0
- package/data/registry/flutter/widgets/sh_ui_checkbox.dart +154 -0
- package/data/registry/flutter/widgets/sh_ui_color_picker.dart +264 -0
- package/data/registry/flutter/widgets/sh_ui_combobox.dart +614 -0
- package/data/registry/flutter/widgets/sh_ui_context_menu.dart +71 -0
- package/data/registry/flutter/widgets/sh_ui_date_picker.dart +648 -0
- package/data/registry/flutter/widgets/sh_ui_dialog.dart +567 -0
- package/data/registry/flutter/widgets/sh_ui_dropdown_menu.dart +251 -0
- package/data/registry/flutter/widgets/sh_ui_file_upload.dart +200 -0
- package/data/registry/flutter/widgets/sh_ui_header.dart +488 -0
- package/data/registry/flutter/widgets/sh_ui_input.dart +664 -0
- package/data/registry/flutter/widgets/sh_ui_label.dart +145 -0
- package/data/registry/flutter/widgets/sh_ui_menubar.dart +98 -0
- package/data/registry/flutter/widgets/sh_ui_pagination.dart +276 -0
- package/data/registry/flutter/widgets/sh_ui_popover.dart +248 -0
- package/data/registry/flutter/widgets/sh_ui_progress.dart +47 -0
- package/data/registry/flutter/widgets/sh_ui_radio.dart +108 -0
- package/data/registry/flutter/widgets/sh_ui_select.dart +904 -0
- package/data/registry/flutter/widgets/sh_ui_separator.dart +42 -0
- package/data/registry/flutter/widgets/sh_ui_sidebar.dart +1116 -0
- package/data/registry/flutter/widgets/sh_ui_skeleton.dart +129 -0
- package/data/registry/flutter/widgets/sh_ui_slider.dart +147 -0
- package/data/registry/flutter/widgets/sh_ui_spinner.dart +56 -0
- package/data/registry/flutter/widgets/sh_ui_switch.dart +109 -0
- package/data/registry/flutter/widgets/sh_ui_tabs.dart +329 -0
- package/data/registry/flutter/widgets/sh_ui_textarea.dart +126 -0
- package/data/registry/flutter/widgets/sh_ui_toast.dart +362 -0
- package/data/registry/flutter/widgets/sh_ui_toggle.dart +229 -0
- package/data/registry/flutter/widgets/sh_ui_tooltip.dart +62 -0
- package/data/registry/react/components/accordion/index.tsx +85 -0
- package/data/registry/react/components/accordion/styles.css +94 -0
- package/data/registry/react/components/animations/animations.css +51 -0
- package/data/registry/react/components/avatar/index.tsx +75 -0
- package/data/registry/react/components/avatar/styles.css +36 -0
- package/data/registry/react/components/badge/index.tsx +42 -0
- package/data/registry/react/components/badge/styles.css +57 -0
- package/data/registry/react/components/base/base.css +102 -0
- package/data/registry/react/components/breadcrumb/index.tsx +154 -0
- package/data/registry/react/components/breadcrumb/styles.css +82 -0
- package/data/registry/react/components/breakpoints/breakpoints.css +17 -0
- package/data/registry/react/components/button/index.tsx +47 -0
- package/data/registry/react/components/button/styles.css +93 -0
- package/data/registry/react/components/card/index.tsx +86 -0
- package/data/registry/react/components/card/styles.css +73 -0
- package/data/registry/react/components/carousel/index.tsx +432 -0
- package/data/registry/react/components/carousel/styles.css +155 -0
- package/data/registry/react/components/checkbox/index.tsx +98 -0
- package/data/registry/react/components/checkbox/styles.css +75 -0
- package/data/registry/react/components/code-panel/copy.tsx +56 -0
- package/data/registry/react/components/code-panel/index.tsx +193 -0
- package/data/registry/react/components/code-panel/styles.css +124 -0
- package/data/registry/react/components/color-picker/index.tsx +466 -0
- package/data/registry/react/components/color-picker/styles.css +166 -0
- package/data/registry/react/components/combobox/index.tsx +167 -0
- package/data/registry/react/components/combobox/styles.css +151 -0
- package/data/registry/react/components/context-menu/index.tsx +253 -0
- package/data/registry/react/components/context-menu/styles.css +140 -0
- package/data/registry/react/components/date-picker/index.tsx +757 -0
- package/data/registry/react/components/date-picker/styles.css +279 -0
- package/data/registry/react/components/dialog/index.tsx +97 -0
- package/data/registry/react/components/dialog/styles.css +127 -0
- package/data/registry/react/components/dropdown-menu/index.tsx +257 -0
- package/data/registry/react/components/dropdown-menu/styles.css +150 -0
- package/data/registry/react/components/file-upload/index.tsx +489 -0
- package/data/registry/react/components/file-upload/styles.css +170 -0
- package/data/registry/react/components/focus-ring/focus-ring.css +23 -0
- package/data/registry/react/components/form/context.ts +92 -0
- package/data/registry/react/components/form/field.test.tsx +230 -0
- package/data/registry/react/components/form/field.tsx +236 -0
- package/data/registry/react/components/form/focus-first-error.ts +54 -0
- package/data/registry/react/components/form/form.section.test.tsx +58 -0
- package/data/registry/react/components/form/form.test.tsx +146 -0
- package/data/registry/react/components/form/form.tsx +180 -0
- package/data/registry/react/components/form/index.tsx +61 -0
- package/data/registry/react/components/form/steps.test.tsx +106 -0
- package/data/registry/react/components/form/steps.tsx +193 -0
- package/data/registry/react/components/form/store.test.ts +206 -0
- package/data/registry/react/components/form/store.ts +318 -0
- package/data/registry/react/components/form/styles.css +47 -0
- package/data/registry/react/components/form/types.ts +104 -0
- package/data/registry/react/components/form/use-sh-ui-form.ts +15 -0
- package/data/registry/react/components/form/utils.test.ts +44 -0
- package/data/registry/react/components/form/utils.ts +49 -0
- package/data/registry/react/components/form/validation.test.ts +67 -0
- package/data/registry/react/components/form/validation.ts +64 -0
- package/data/registry/react/components/form-rhf/README.md +27 -0
- package/data/registry/react/components/form-rhf/index.tsx +289 -0
- package/data/registry/react/components/form-rhf/rhf.test.tsx +42 -0
- package/data/registry/react/components/form-tanstack/README.md +27 -0
- package/data/registry/react/components/form-tanstack/index.tsx +352 -0
- package/data/registry/react/components/form-tanstack/tanstack.test.tsx +45 -0
- package/data/registry/react/components/form-yup/README.md +22 -0
- package/data/registry/react/components/form-yup/index.tsx +50 -0
- package/data/registry/react/components/form-yup/yup.test.ts +27 -0
- package/data/registry/react/components/header/index.tsx +257 -0
- package/data/registry/react/components/header/styles.css +190 -0
- package/data/registry/react/components/input/index.tsx +517 -0
- package/data/registry/react/components/input/styles.css +203 -0
- package/data/registry/react/components/label/index.tsx +54 -0
- package/data/registry/react/components/label/styles.css +90 -0
- package/data/registry/react/components/menubar/index.tsx +34 -0
- package/data/registry/react/components/menubar/styles.css +45 -0
- package/data/registry/react/components/pagination/index.tsx +271 -0
- package/data/registry/react/components/pagination/styles.css +105 -0
- package/data/registry/react/components/popover/index.tsx +115 -0
- package/data/registry/react/components/popover/styles.css +65 -0
- package/data/registry/react/components/progress/index.tsx +56 -0
- package/data/registry/react/components/progress/styles.css +41 -0
- package/data/registry/react/components/radio/index.tsx +67 -0
- package/data/registry/react/components/radio/styles.css +80 -0
- package/data/registry/react/components/select/index.tsx +236 -0
- package/data/registry/react/components/select/styles.css +193 -0
- package/data/registry/react/components/separator/index.tsx +48 -0
- package/data/registry/react/components/separator/styles.css +15 -0
- package/data/registry/react/components/sidebar/index.tsx +1084 -0
- package/data/registry/react/components/sidebar/styles.css +502 -0
- package/data/registry/react/components/skeleton/index.tsx +24 -0
- package/data/registry/react/components/skeleton/styles.css +24 -0
- package/data/registry/react/components/slider/index.tsx +300 -0
- package/data/registry/react/components/slider/styles.css +64 -0
- package/data/registry/react/components/spinner/index.tsx +40 -0
- package/data/registry/react/components/spinner/styles.css +37 -0
- package/data/registry/react/components/switch/index.tsx +41 -0
- package/data/registry/react/components/switch/styles.css +83 -0
- package/data/registry/react/components/tabs/index.tsx +93 -0
- package/data/registry/react/components/tabs/styles.css +148 -0
- package/data/registry/react/components/textarea/index.tsx +25 -0
- package/data/registry/react/components/textarea/styles.css +54 -0
- package/data/registry/react/components/theme/index.tsx +91 -0
- package/data/registry/react/components/toast/index.tsx +257 -0
- package/data/registry/react/components/toast/styles.css +290 -0
- package/data/registry/react/components/toggle/index.tsx +133 -0
- package/data/registry/react/components/toggle/styles.css +85 -0
- package/data/registry/react/components/tooltip/index.tsx +85 -0
- package/data/registry/react/components/tooltip/styles.css +44 -0
- package/data/registry/react/components/z-index/z-index.css +16 -0
- package/data/registry/react/hooks/use-active-section.ts +104 -0
- package/data/registry/react/hooks/use-media-query.ts +27 -0
- package/data/registry/react/lib/cn.ts +39 -0
- package/data/registry/react/peer-versions.json +10 -0
- package/data/registry/react/registry.json +835 -0
- package/data/summaries/flutter.json +42 -0
- package/data/summaries/react.json +50 -0
- package/data/tokens/build.mjs +553 -0
- package/data/tokens/src/primitives.json +146 -0
- package/data/tokens/src/semantic.json +146 -0
- package/package.json +9 -2
- package/src/add.mjs +41 -15
- package/src/list.mjs +3 -11
- package/src/mcp.mjs +308 -0
- package/src/paths.mjs +59 -0
- package/src/remove.mjs +4 -11
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import 'package:flutter/material.dart';
|
|
2
|
+
import '../foundation/sh_ui_tokens.dart';
|
|
3
|
+
|
|
4
|
+
/// sh-ui DropdownMenu — 트리거에서 펼쳐지는 명령 메뉴.
|
|
5
|
+
///
|
|
6
|
+
/// Flutter의 [PopupMenuButton]을 sh-ui 토큰으로 스타일링한 래퍼.
|
|
7
|
+
/// 체크박스·라디오 항목과 구분선·라벨을 지원한다.
|
|
8
|
+
///
|
|
9
|
+
/// ShUiDropdownMenu<String>(
|
|
10
|
+
/// child: ShUiButton(onPressed: null, child: Text('메뉴')),
|
|
11
|
+
/// items: const [
|
|
12
|
+
/// ShUiDropdownMenuItem(value: 'new', label: '새로 만들기'),
|
|
13
|
+
/// ShUiDropdownMenuItem(value: 'open', label: '열기'),
|
|
14
|
+
/// ShUiDropdownMenuDivider(),
|
|
15
|
+
/// ShUiDropdownMenuItem(value: 'close', label: '닫기'),
|
|
16
|
+
/// ],
|
|
17
|
+
/// onSelected: (value) => print(value),
|
|
18
|
+
/// )
|
|
19
|
+
class ShUiDropdownMenu<T> extends StatelessWidget {
|
|
20
|
+
/// 트리거 위젯. 탭하면 메뉴가 펼쳐진다.
|
|
21
|
+
final Widget child;
|
|
22
|
+
|
|
23
|
+
/// 메뉴 항목 목록.
|
|
24
|
+
final List<ShUiDropdownMenuEntry<T>> items;
|
|
25
|
+
|
|
26
|
+
/// 항목 선택 콜백. null이면 비활성.
|
|
27
|
+
final ValueChanged<T>? onSelected;
|
|
28
|
+
|
|
29
|
+
/// 트리거에 대한 배치 방향. 기본 bottom.
|
|
30
|
+
final ShUiDropdownMenuSide side;
|
|
31
|
+
|
|
32
|
+
/// 툴팁 텍스트. PopupMenuButton의 기본 툴팁을 덮어쓴다.
|
|
33
|
+
final String? tooltip;
|
|
34
|
+
|
|
35
|
+
const ShUiDropdownMenu({
|
|
36
|
+
super.key,
|
|
37
|
+
required this.child,
|
|
38
|
+
required this.items,
|
|
39
|
+
this.onSelected,
|
|
40
|
+
this.side = ShUiDropdownMenuSide.bottom,
|
|
41
|
+
this.tooltip,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
@override
|
|
45
|
+
Widget build(BuildContext context) {
|
|
46
|
+
final shUi = Theme.of(context).extension<ShUiTheme>() ?? ShUiTheme.light;
|
|
47
|
+
final colors = shUi.colors;
|
|
48
|
+
|
|
49
|
+
return PopupMenuButton<T>(
|
|
50
|
+
tooltip: tooltip ?? '',
|
|
51
|
+
onSelected: onSelected,
|
|
52
|
+
enabled: onSelected != null,
|
|
53
|
+
color: colors.background,
|
|
54
|
+
surfaceTintColor: Colors.transparent,
|
|
55
|
+
shadowColor: Colors.black.withValues(alpha: 0.12),
|
|
56
|
+
elevation: 4,
|
|
57
|
+
position: side == ShUiDropdownMenuSide.top
|
|
58
|
+
? PopupMenuPosition.over
|
|
59
|
+
: PopupMenuPosition.under,
|
|
60
|
+
shape: RoundedRectangleBorder(
|
|
61
|
+
borderRadius: BorderRadius.circular(shUi.radius.defaultRadius),
|
|
62
|
+
side: BorderSide(color: colors.border),
|
|
63
|
+
),
|
|
64
|
+
padding: const EdgeInsets.all(4),
|
|
65
|
+
itemBuilder: (context) =>
|
|
66
|
+
items.map((e) => e.toPopupMenuEntry(context)).toList(),
|
|
67
|
+
child: child,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
enum ShUiDropdownMenuSide { top, bottom }
|
|
73
|
+
|
|
74
|
+
/// DropdownMenu 항목의 베이스 인터페이스.
|
|
75
|
+
abstract class ShUiDropdownMenuEntry<T> {
|
|
76
|
+
const ShUiDropdownMenuEntry();
|
|
77
|
+
PopupMenuEntry<T> toPopupMenuEntry(BuildContext context);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/// 기본 명령 항목.
|
|
81
|
+
class ShUiDropdownMenuItem<T> extends ShUiDropdownMenuEntry<T> {
|
|
82
|
+
final T value;
|
|
83
|
+
final String? label;
|
|
84
|
+
final Widget? child;
|
|
85
|
+
final Widget? leading;
|
|
86
|
+
final bool disabled;
|
|
87
|
+
|
|
88
|
+
const ShUiDropdownMenuItem({
|
|
89
|
+
required this.value,
|
|
90
|
+
this.label,
|
|
91
|
+
this.child,
|
|
92
|
+
this.leading,
|
|
93
|
+
this.disabled = false,
|
|
94
|
+
}) : assert(label != null || child != null, 'label 또는 child 중 하나는 필요');
|
|
95
|
+
|
|
96
|
+
@override
|
|
97
|
+
PopupMenuEntry<T> toPopupMenuEntry(BuildContext context) {
|
|
98
|
+
return _buildItem(
|
|
99
|
+
context: context,
|
|
100
|
+
value: value,
|
|
101
|
+
disabled: disabled,
|
|
102
|
+
leading: leading,
|
|
103
|
+
label: label,
|
|
104
|
+
child: child,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/// 체크박스 항목 — checked 상태면 ✓ 인디케이터 표시.
|
|
110
|
+
class ShUiDropdownMenuCheckboxItem<T> extends ShUiDropdownMenuEntry<T> {
|
|
111
|
+
final T value;
|
|
112
|
+
final String? label;
|
|
113
|
+
final Widget? child;
|
|
114
|
+
final bool checked;
|
|
115
|
+
final bool disabled;
|
|
116
|
+
|
|
117
|
+
const ShUiDropdownMenuCheckboxItem({
|
|
118
|
+
required this.value,
|
|
119
|
+
this.label,
|
|
120
|
+
this.child,
|
|
121
|
+
required this.checked,
|
|
122
|
+
this.disabled = false,
|
|
123
|
+
}) : assert(label != null || child != null);
|
|
124
|
+
|
|
125
|
+
@override
|
|
126
|
+
PopupMenuEntry<T> toPopupMenuEntry(BuildContext context) {
|
|
127
|
+
return _buildItem(
|
|
128
|
+
context: context,
|
|
129
|
+
value: value,
|
|
130
|
+
disabled: disabled,
|
|
131
|
+
leading: checked
|
|
132
|
+
? const Icon(Icons.check, size: 14)
|
|
133
|
+
: const SizedBox(width: 14),
|
|
134
|
+
label: label,
|
|
135
|
+
child: child,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/// 라디오 항목 — selected 상태면 ● 인디케이터 표시.
|
|
141
|
+
class ShUiDropdownMenuRadioItem<T> extends ShUiDropdownMenuEntry<T> {
|
|
142
|
+
final T value;
|
|
143
|
+
final String? label;
|
|
144
|
+
final Widget? child;
|
|
145
|
+
final bool selected;
|
|
146
|
+
final bool disabled;
|
|
147
|
+
|
|
148
|
+
const ShUiDropdownMenuRadioItem({
|
|
149
|
+
required this.value,
|
|
150
|
+
this.label,
|
|
151
|
+
this.child,
|
|
152
|
+
required this.selected,
|
|
153
|
+
this.disabled = false,
|
|
154
|
+
}) : assert(label != null || child != null);
|
|
155
|
+
|
|
156
|
+
@override
|
|
157
|
+
PopupMenuEntry<T> toPopupMenuEntry(BuildContext context) {
|
|
158
|
+
return _buildItem(
|
|
159
|
+
context: context,
|
|
160
|
+
value: value,
|
|
161
|
+
disabled: disabled,
|
|
162
|
+
leading: selected
|
|
163
|
+
? const Icon(Icons.circle, size: 8)
|
|
164
|
+
: const SizedBox(width: 14),
|
|
165
|
+
label: label,
|
|
166
|
+
child: child,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/// 구분선.
|
|
172
|
+
class ShUiDropdownMenuDivider<T> extends ShUiDropdownMenuEntry<T> {
|
|
173
|
+
const ShUiDropdownMenuDivider();
|
|
174
|
+
|
|
175
|
+
@override
|
|
176
|
+
PopupMenuEntry<T> toPopupMenuEntry(BuildContext context) {
|
|
177
|
+
final shUi = Theme.of(context).extension<ShUiTheme>() ?? ShUiTheme.light;
|
|
178
|
+
return PopupMenuDivider(
|
|
179
|
+
height: 1,
|
|
180
|
+
color: shUi.colors.border,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/// 섹션 라벨 — 탭 불가, 작은 대문자 제목.
|
|
186
|
+
class ShUiDropdownMenuLabel<T> extends ShUiDropdownMenuEntry<T> {
|
|
187
|
+
final String label;
|
|
188
|
+
|
|
189
|
+
const ShUiDropdownMenuLabel(this.label);
|
|
190
|
+
|
|
191
|
+
@override
|
|
192
|
+
PopupMenuEntry<T> toPopupMenuEntry(BuildContext context) {
|
|
193
|
+
final shUi = Theme.of(context).extension<ShUiTheme>() ?? ShUiTheme.light;
|
|
194
|
+
return PopupMenuItem<T>(
|
|
195
|
+
enabled: false,
|
|
196
|
+
height: 28,
|
|
197
|
+
padding: const EdgeInsets.fromLTRB(12, 6, 12, 2),
|
|
198
|
+
child: Text(
|
|
199
|
+
label.toUpperCase(),
|
|
200
|
+
style: TextStyle(
|
|
201
|
+
color: shUi.colors.foregroundMuted,
|
|
202
|
+
fontSize: 11,
|
|
203
|
+
fontWeight: shUi.weight.semibold,
|
|
204
|
+
letterSpacing: 0.4,
|
|
205
|
+
),
|
|
206
|
+
),
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
PopupMenuEntry<T> _buildItem<T>({
|
|
212
|
+
required BuildContext context,
|
|
213
|
+
required T value,
|
|
214
|
+
required bool disabled,
|
|
215
|
+
required Widget? leading,
|
|
216
|
+
required String? label,
|
|
217
|
+
required Widget? child,
|
|
218
|
+
}) {
|
|
219
|
+
final shUi = Theme.of(context).extension<ShUiTheme>() ?? ShUiTheme.light;
|
|
220
|
+
final colors = shUi.colors;
|
|
221
|
+
|
|
222
|
+
return PopupMenuItem<T>(
|
|
223
|
+
value: value,
|
|
224
|
+
enabled: !disabled,
|
|
225
|
+
height: 36,
|
|
226
|
+
padding: const EdgeInsets.symmetric(horizontal: 10),
|
|
227
|
+
child: Row(
|
|
228
|
+
mainAxisSize: MainAxisSize.min,
|
|
229
|
+
children: [
|
|
230
|
+
if (leading != null) ...[
|
|
231
|
+
IconTheme(
|
|
232
|
+
data: IconThemeData(color: colors.foreground, size: 14),
|
|
233
|
+
child: leading,
|
|
234
|
+
),
|
|
235
|
+
const SizedBox(width: 8),
|
|
236
|
+
],
|
|
237
|
+
Expanded(
|
|
238
|
+
child: DefaultTextStyle(
|
|
239
|
+
style: TextStyle(
|
|
240
|
+
color: disabled ? colors.foregroundMuted : colors.foreground,
|
|
241
|
+
fontSize: shUi.text.sm,
|
|
242
|
+
height: 1.2,
|
|
243
|
+
),
|
|
244
|
+
overflow: TextOverflow.ellipsis,
|
|
245
|
+
child: child ?? Text(label ?? ''),
|
|
246
|
+
),
|
|
247
|
+
),
|
|
248
|
+
],
|
|
249
|
+
),
|
|
250
|
+
);
|
|
251
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import 'package:flutter/material.dart';
|
|
2
|
+
import '../foundation/sh_ui_tokens.dart';
|
|
3
|
+
|
|
4
|
+
/// 파일 정보 (플랫폼 독립적).
|
|
5
|
+
class ShUiFileInfo {
|
|
6
|
+
final String name;
|
|
7
|
+
final int size;
|
|
8
|
+
|
|
9
|
+
const ShUiFileInfo({required this.name, required this.size});
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/// sh-ui FileUpload — 파일 업로드 UI.
|
|
13
|
+
///
|
|
14
|
+
/// Flutter에서 실제 파일 선택은 file_picker 등 패키지에 의존한다.
|
|
15
|
+
/// 이 위젯은 UI만 제공하며, onPickFiles 콜백으로 파일 선택 로직을 위임한다.
|
|
16
|
+
///
|
|
17
|
+
/// ShUiFileUpload(
|
|
18
|
+
/// files: uploadedFiles,
|
|
19
|
+
/// onPickFiles: () async {
|
|
20
|
+
/// final result = await FilePicker.platform.pickFiles(allowMultiple: true);
|
|
21
|
+
/// if (result != null) {
|
|
22
|
+
/// setState(() { uploadedFiles.addAll(result.files.map(...)); });
|
|
23
|
+
/// }
|
|
24
|
+
/// },
|
|
25
|
+
/// onRemove: (index) => setState(() => uploadedFiles.removeAt(index)),
|
|
26
|
+
/// )
|
|
27
|
+
class ShUiFileUpload extends StatefulWidget {
|
|
28
|
+
final List<ShUiFileInfo> files;
|
|
29
|
+
final VoidCallback? onPickFiles;
|
|
30
|
+
final ValueChanged<int>? onRemove;
|
|
31
|
+
final bool multiple;
|
|
32
|
+
final bool enabled;
|
|
33
|
+
final Widget? placeholder;
|
|
34
|
+
final String? hint;
|
|
35
|
+
final bool showFileList;
|
|
36
|
+
|
|
37
|
+
const ShUiFileUpload({
|
|
38
|
+
super.key,
|
|
39
|
+
this.files = const [],
|
|
40
|
+
this.onPickFiles,
|
|
41
|
+
this.onRemove,
|
|
42
|
+
this.multiple = false,
|
|
43
|
+
this.enabled = true,
|
|
44
|
+
this.placeholder,
|
|
45
|
+
this.hint,
|
|
46
|
+
this.showFileList = true,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
@override
|
|
50
|
+
State<ShUiFileUpload> createState() => _ShUiFileUploadState();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
class _ShUiFileUploadState extends State<ShUiFileUpload> {
|
|
54
|
+
bool _hover = false;
|
|
55
|
+
|
|
56
|
+
String _formatBytes(int bytes) {
|
|
57
|
+
if (bytes < 1024) return '$bytes B';
|
|
58
|
+
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
|
59
|
+
if (bytes < 1024 * 1024 * 1024) {
|
|
60
|
+
return '${(bytes / 1024 / 1024).toStringAsFixed(1)} MB';
|
|
61
|
+
}
|
|
62
|
+
return '${(bytes / 1024 / 1024 / 1024).toStringAsFixed(1)} GB';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
@override
|
|
66
|
+
Widget build(BuildContext context) {
|
|
67
|
+
final shUi = Theme.of(context).extension<ShUiTheme>() ?? ShUiTheme.light;
|
|
68
|
+
final colors = shUi.colors;
|
|
69
|
+
final disabled = !widget.enabled;
|
|
70
|
+
|
|
71
|
+
return Column(
|
|
72
|
+
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
73
|
+
mainAxisSize: MainAxisSize.min,
|
|
74
|
+
children: [
|
|
75
|
+
// Dropzone
|
|
76
|
+
MouseRegion(
|
|
77
|
+
cursor: disabled ? SystemMouseCursors.basic : SystemMouseCursors.click,
|
|
78
|
+
onEnter: (_) => setState(() => _hover = true),
|
|
79
|
+
onExit: (_) => setState(() => _hover = false),
|
|
80
|
+
child: GestureDetector(
|
|
81
|
+
onTap: disabled ? null : widget.onPickFiles,
|
|
82
|
+
child: Opacity(
|
|
83
|
+
opacity: disabled ? shUi.opacity.disabled : 1,
|
|
84
|
+
child: AnimatedContainer(
|
|
85
|
+
duration: shUi.duration.fast,
|
|
86
|
+
padding:
|
|
87
|
+
EdgeInsets.symmetric(horizontal: shUi.spacing.s6, vertical: shUi.spacing.s8),
|
|
88
|
+
decoration: BoxDecoration(
|
|
89
|
+
color: _hover
|
|
90
|
+
? colors.backgroundSubtle
|
|
91
|
+
: colors.background,
|
|
92
|
+
border: Border.all(
|
|
93
|
+
color: _hover ? colors.primary : colors.border,
|
|
94
|
+
width: 1.5,
|
|
95
|
+
),
|
|
96
|
+
borderRadius:
|
|
97
|
+
BorderRadius.circular(shUi.radius.defaultRadius),
|
|
98
|
+
),
|
|
99
|
+
child: Column(
|
|
100
|
+
mainAxisSize: MainAxisSize.min,
|
|
101
|
+
children: [
|
|
102
|
+
Icon(
|
|
103
|
+
Icons.upload_outlined,
|
|
104
|
+
size: 28,
|
|
105
|
+
color: colors.foregroundMuted,
|
|
106
|
+
),
|
|
107
|
+
SizedBox(height: shUi.spacing.s2),
|
|
108
|
+
widget.placeholder ??
|
|
109
|
+
Text.rich(
|
|
110
|
+
TextSpan(children: [
|
|
111
|
+
TextSpan(
|
|
112
|
+
text: '파일을 선택',
|
|
113
|
+
style: TextStyle(
|
|
114
|
+
fontWeight: shUi.weight.semibold,
|
|
115
|
+
color: colors.foreground,
|
|
116
|
+
),
|
|
117
|
+
),
|
|
118
|
+
TextSpan(
|
|
119
|
+
text: '하거나 드래그하세요',
|
|
120
|
+
style: TextStyle(color: colors.foregroundMuted),
|
|
121
|
+
),
|
|
122
|
+
]),
|
|
123
|
+
style: TextStyle(fontSize: shUi.text.sm),
|
|
124
|
+
),
|
|
125
|
+
if (widget.hint != null) ...[
|
|
126
|
+
SizedBox(height: shUi.spacing.s1),
|
|
127
|
+
Text(
|
|
128
|
+
widget.hint!,
|
|
129
|
+
style: TextStyle(
|
|
130
|
+
color: colors.foregroundMuted,
|
|
131
|
+
fontSize: shUi.text.xs,
|
|
132
|
+
),
|
|
133
|
+
),
|
|
134
|
+
],
|
|
135
|
+
],
|
|
136
|
+
),
|
|
137
|
+
),
|
|
138
|
+
),
|
|
139
|
+
),
|
|
140
|
+
),
|
|
141
|
+
|
|
142
|
+
// File list
|
|
143
|
+
if (widget.showFileList && widget.files.isNotEmpty) ...[
|
|
144
|
+
SizedBox(height: shUi.spacing.s2),
|
|
145
|
+
...List.generate(widget.files.length, (i) {
|
|
146
|
+
final file = widget.files[i];
|
|
147
|
+
return Padding(
|
|
148
|
+
padding: EdgeInsets.only(bottom: shUi.spacing.s1),
|
|
149
|
+
child: Container(
|
|
150
|
+
padding:
|
|
151
|
+
EdgeInsets.symmetric(horizontal: shUi.spacing.s3, vertical: shUi.spacing.s2),
|
|
152
|
+
decoration: BoxDecoration(
|
|
153
|
+
color: colors.backgroundSubtle,
|
|
154
|
+
borderRadius:
|
|
155
|
+
BorderRadius.circular(shUi.radius.defaultRadius - 2),
|
|
156
|
+
),
|
|
157
|
+
child: Row(
|
|
158
|
+
children: [
|
|
159
|
+
Icon(Icons.insert_drive_file_outlined,
|
|
160
|
+
size: 16, color: colors.foregroundMuted),
|
|
161
|
+
SizedBox(width: shUi.spacing.s2),
|
|
162
|
+
Expanded(
|
|
163
|
+
child: Text(
|
|
164
|
+
file.name,
|
|
165
|
+
overflow: TextOverflow.ellipsis,
|
|
166
|
+
style: TextStyle(
|
|
167
|
+
color: colors.foreground,
|
|
168
|
+
fontSize: 13,
|
|
169
|
+
),
|
|
170
|
+
),
|
|
171
|
+
),
|
|
172
|
+
SizedBox(width: shUi.spacing.s2),
|
|
173
|
+
Text(
|
|
174
|
+
_formatBytes(file.size),
|
|
175
|
+
style: TextStyle(
|
|
176
|
+
color: colors.foregroundMuted,
|
|
177
|
+
fontSize: shUi.text.xs,
|
|
178
|
+
),
|
|
179
|
+
),
|
|
180
|
+
if (widget.onRemove != null) ...[
|
|
181
|
+
SizedBox(width: shUi.spacing.s2),
|
|
182
|
+
GestureDetector(
|
|
183
|
+
onTap: disabled ? null : () => widget.onRemove!(i),
|
|
184
|
+
child: Icon(
|
|
185
|
+
Icons.close,
|
|
186
|
+
size: 14,
|
|
187
|
+
color: colors.foregroundMuted,
|
|
188
|
+
),
|
|
189
|
+
),
|
|
190
|
+
],
|
|
191
|
+
],
|
|
192
|
+
),
|
|
193
|
+
),
|
|
194
|
+
);
|
|
195
|
+
}),
|
|
196
|
+
],
|
|
197
|
+
],
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
}
|