Line data Source code
1 : import 'package:flutter/material.dart';
2 : import 'package:flutter/services.dart';
3 : import 'package:provider/provider.dart';
4 : import 'package:intl/intl.dart';
5 :
6 : import '../../utils/url_utils.dart';
7 : import '../../utils/test_utils.dart';
8 :
9 : import '../../models/event_model.dart';
10 : import '../../services/auth_service.dart';
11 : import '../../services/event_service.dart';
12 : import '../../widgets/loading_indicator.dart';
13 :
14 : class EventFormScreen extends StatefulWidget {
15 : final Event? event; // null이면 새 이벤트 생성, 아니면 이벤트 수정
16 :
17 25 : const EventFormScreen({
18 : Key? key,
19 : this.event,
20 1 : }) : super(key: key);
21 :
22 1 : @override
23 1 : State<EventFormScreen> createState() => _EventFormScreenState();
24 : }
25 :
26 : class _EventFormScreenState extends State<EventFormScreen> {
27 : final _formKey = GlobalKey<FormState>();
28 : bool _isLoading = false;
29 : bool _isInit = false;
30 : bool _obscurePassword = true; // 비밀번호 숨김 상태 관리
31 : final _currencyFormat = NumberFormat.currency(locale: 'ko_KR', symbol: '₩', decimalDigits: 0);
32 :
33 : // 폼 필드 컨트롤러
34 : final TextEditingController _titleController = TextEditingController();
35 : final TextEditingController _descriptionController = TextEditingController();
36 : final TextEditingController _locationController = TextEditingController();
37 : final TextEditingController _maxParticipantsController = TextEditingController();
38 : final TextEditingController _gameCountController = TextEditingController();
39 : final TextEditingController _participantFeeController = TextEditingController();
40 : final TextEditingController _formattedFeeController = TextEditingController(); // 포맷된 참가비 표시용
41 : final TextEditingController _publicHashController = TextEditingController();
42 : final TextEditingController _accessPasswordController = TextEditingController();
43 :
44 : // 이벤트 데이터
45 : String? _type;
46 : String? _status;
47 : DateTime _startDate = DateTime.now();
48 : DateTime? _endDate;
49 : DateTime? _registrationDeadline;
50 :
51 : // 이벤트 유형 및 상태 옵션
52 : final List<String> _typeOptions = ['개인전', '팀전', '리그', '토너먼트', '연습', '기타'];
53 : final List<String> _statusOptions = ['활성', '대기', '취소', '완료'];
54 :
55 : // 이벤트 유형 및 상태별 색상
56 : final Map<String, Color> _typeColors = {
57 : '개인전': Colors.blue,
58 : '팀전': Colors.green,
59 : '리그': Colors.purple,
60 : '토너먼트': Colors.orange,
61 : '연습': Colors.teal,
62 : '기타': Colors.grey,
63 : };
64 :
65 : final Map<String, Color> _statusColors = {
66 : '활성': Colors.green,
67 : '대기': Colors.amber,
68 : '취소': Colors.red,
69 : '완료': Colors.blue,
70 : };
71 :
72 : // 이벤트 유형 및 상태별 아이콘
73 : final Map<String, IconData> _typeIcons = {
74 : '개인전': Icons.person,
75 : '팀전': Icons.group,
76 : '리그': Icons.sports,
77 : '토너먼트': Icons.emoji_events,
78 : '연습': Icons.fitness_center,
79 : '기타': Icons.category,
80 : };
81 :
82 : final Map<String, IconData> _statusIcons = {
83 : '활성': Icons.check_circle,
84 : '대기': Icons.hourglass_empty,
85 : '취소': Icons.cancel,
86 : '완료': Icons.done_all,
87 : };
88 :
89 1 : @override
90 : void initState() {
91 1 : super.initState();
92 :
93 2 : if (widget.event != null) {
94 : // 기존 이벤트 수정 시 필드 초기화
95 0 : _titleController.text = widget.event!.title;
96 0 : _descriptionController.text = widget.event!.description ?? '';
97 0 : _locationController.text = widget.event!.location ?? '';
98 0 : _maxParticipantsController.text = widget.event!.maxParticipants?.toString() ?? '';
99 0 : _gameCountController.text = widget.event!.gameCount?.toString() ?? '';
100 0 : _publicHashController.text = widget.event!.publicHash ?? '';
101 0 : _accessPasswordController.text = widget.event!.accessPassword ?? '';
102 :
103 0 : _type = widget.event!.type;
104 0 : _status = widget.event!.status;
105 0 : _startDate = widget.event!.startDate;
106 0 : _endDate = widget.event!.endDate;
107 0 : _registrationDeadline = widget.event!.registrationDeadline;
108 :
109 : // 참가비가 있으면 포맷된 참가비 컨트롤러 초기화
110 0 : if (widget.event!.participantFee != null) {
111 0 : _updateFormattedFee(widget.event!.participantFee.toString());
112 : }
113 : } else {
114 : // 새 이벤트 생성 시 기본값 설정
115 3 : _type = _typeOptions[0];
116 3 : _status = _statusOptions[0];
117 : }
118 :
119 : // 참가비 입력 컨트롤러에 리스너 추가
120 3 : _participantFeeController.addListener(_onParticipantFeeChanged);
121 : }
122 :
123 : bool _isHashGenerated = false;
124 :
125 1 : @override
126 : void didChangeDependencies() {
127 1 : super.didChangeDependencies();
128 :
129 : // 새 이벤트 생성 시에만 해시 자동 생성 (한 번만 실행되도록 플래그 사용)
130 6 : if (widget.event == null && _publicHashController.text.isEmpty && !_isHashGenerated) {
131 1 : _isHashGenerated = true;
132 : // 다음 프레임에서 해시 생성 (context 사용 문제 방지)
133 3 : WidgetsBinding.instance.addPostFrameCallback((_) {
134 1 : if (mounted) {
135 1 : _generatePublicHash();
136 : }
137 : });
138 : }
139 : }
140 :
141 1 : @override
142 : void dispose() {
143 2 : _titleController.dispose();
144 2 : _descriptionController.dispose();
145 2 : _locationController.dispose();
146 2 : _maxParticipantsController.dispose();
147 2 : _gameCountController.dispose();
148 3 : _participantFeeController.removeListener(_onParticipantFeeChanged);
149 2 : _participantFeeController.dispose();
150 2 : _formattedFeeController.dispose();
151 2 : _publicHashController.dispose();
152 2 : _accessPasswordController.dispose();
153 1 : super.dispose();
154 : }
155 :
156 : // 공개 URL 해시 생성
157 1 : void _generatePublicHash() {
158 : // 이전 해시 저장
159 2 : final previousHash = _publicHashController.text;
160 1 : final isNew = previousHash.isEmpty;
161 :
162 : // 새 해시 생성
163 1 : final hash = UrlUtils.generatePublicHash();
164 :
165 2 : setState(() {
166 2 : _publicHashController.text = hash;
167 : });
168 :
169 : // 해시 생성 후 사용자에게 피드백 제공
170 : // 테스트 환경에서는 로그만 출력하고, 일반 환경에서만 스낵바 표시
171 1 : if (mounted) {
172 1 : if (TestUtils.isInTest) {
173 3 : debugPrint('해시 생성 완료: $hash');
174 : } else {
175 : // 일반 환경에서는 다음 프레임에서 스낵바 표시
176 0 : WidgetsBinding.instance.addPostFrameCallback((_) {
177 0 : if (mounted) {
178 : try {
179 0 : final scaffoldMessenger = ScaffoldMessenger.of(context);
180 : final message = isNew
181 : ? '공개 URL 해시가 생성되었습니다'
182 : : '공개 URL 해시가 재생성되었습니다. 이전 URL은 더 이상 유효하지 않습니다';
183 :
184 0 : scaffoldMessenger.showSnackBar(
185 0 : SnackBar(
186 0 : content: Text(message),
187 0 : action: SnackBarAction(
188 : label: 'URL 복사',
189 0 : onPressed: () {
190 0 : final url = UrlUtils.getEventPublicUrl(hash);
191 0 : UrlUtils.copyUrlToClipboard(context, url);
192 : },
193 : ),
194 : ),
195 : );
196 : } catch (e) {
197 0 : debugPrint('스낵바 표시 오류: $e');
198 : }
199 : }
200 : });
201 : }
202 : }
203 : }
204 :
205 : // 이벤트 저장
206 1 : Future<void> _saveEvent() async {
207 3 : final isValid = _formKey.currentState!.validate();
208 :
209 : if (!isValid) {
210 : // 유효성 검사 실패 시 스낵바로 알림
211 1 : if (mounted) {
212 : // 테스트 환경에서 안전하게 SnackBar 표시
213 1 : if (TestUtils.isInTest) {
214 2 : debugPrint('유효성 검사 실패: 필수 입력 항목을 모두 작성해주세요');
215 : } else {
216 : // 일반 환경에서는 SnackBar 표시
217 0 : WidgetsBinding.instance.addPostFrameCallback((_) {
218 0 : if (mounted) {
219 0 : final scaffoldMessenger = ScaffoldMessenger.of(context);
220 0 : scaffoldMessenger.hideCurrentSnackBar();
221 0 : scaffoldMessenger.showSnackBar(
222 : const SnackBar(
223 : content: Text('필수 입력 항목을 모두 작성해주세요 (제목, 이벤트 유형, 상태, 시작 날짜)'),
224 : backgroundColor: Colors.red,
225 : duration: Duration(seconds: 3),
226 : ),
227 : );
228 : }
229 : });
230 : }
231 : }
232 : return;
233 : }
234 :
235 0 : setState(() {
236 0 : _isLoading = true;
237 : });
238 :
239 : try {
240 0 : final eventService = Provider.of<EventService>(context, listen: false);
241 0 : final authService = Provider.of<AuthService>(context, listen: false);
242 :
243 : // 이벤트 데이터 준비 - null 값 처리 개선
244 0 : final eventData = {
245 0 : 'title': _titleController.text.trim(),
246 0 : 'description': _descriptionController.text.trim().isEmpty ? null : _descriptionController.text.trim(),
247 0 : 'location': _locationController.text.trim().isEmpty ? null : _locationController.text.trim(),
248 0 : 'type': _type,
249 0 : 'status': _status,
250 0 : 'startDate': _startDate.toIso8601String(),
251 0 : 'endDate': _endDate?.toIso8601String(),
252 0 : 'maxParticipants': _maxParticipantsController.text.isEmpty ? null : int.parse(_maxParticipantsController.text),
253 0 : 'gameCount': _gameCountController.text.isEmpty ? null : int.parse(_gameCountController.text),
254 0 : 'participantFee': _participantFeeController.text.isEmpty ? null : double.parse(_participantFeeController.text),
255 0 : 'registrationDeadline': _registrationDeadline?.toIso8601String(),
256 0 : 'publicHash': _publicHashController.text.trim().isEmpty ? null : _publicHashController.text.trim(),
257 0 : 'accessPassword': _accessPasswordController.text.trim().isEmpty ? null : _accessPasswordController.text.trim(),
258 : };
259 :
260 : // 서버에 명시적으로 null 값을 전송하기 위해 빈 문자열을 null로 변환
261 : // JavaScript에서 || 연산자로 인한 문제 방지
262 0 : eventData.forEach((key, value) {
263 0 : if (value is String && value.isEmpty) {
264 0 : eventData[key] = null;
265 : }
266 : });
267 :
268 : // 디버그용 로그 - 개발 완료 후 제거 필요
269 0 : debugPrint('Event data to send: $eventData');
270 :
271 0 : if (widget.event == null) {
272 : // 새 이벤트 생성
273 0 : await eventService.createEvent(eventData);
274 0 : if (mounted) {
275 : // 테스트 환경에서 안전하게 SnackBar 표시
276 0 : if (TestUtils.isInTest) {
277 0 : debugPrint('이벤트가 생성되었습니다');
278 : } else {
279 0 : ScaffoldMessenger.of(context).showSnackBar(
280 0 : SnackBar(
281 : content: const Text('이벤트가 생성되었습니다'),
282 0 : action: SnackBarAction(
283 : label: '확인',
284 0 : onPressed: () {},
285 : ),
286 : duration: const Duration(seconds: 3),
287 : ),
288 : );
289 : }
290 : }
291 : } else {
292 : // 기존 이벤트 수정
293 0 : await eventService.updateEvent(widget.event!.id, eventData);
294 0 : if (mounted) {
295 : // 테스트 환경에서 안전하게 SnackBar 표시
296 0 : if (TestUtils.isInTest) {
297 0 : debugPrint('이벤트가 수정되었습니다');
298 : } else {
299 0 : ScaffoldMessenger.of(context).showSnackBar(
300 0 : SnackBar(
301 : content: const Text('이벤트가 수정되었습니다'),
302 0 : action: SnackBarAction(
303 : label: '확인',
304 0 : onPressed: () {},
305 : ),
306 : duration: const Duration(seconds: 3),
307 : ),
308 : );
309 : }
310 : }
311 : }
312 :
313 0 : if (mounted) {
314 0 : Navigator.of(context).pop(true); // 성공 결과와 함께 이전 화면으로 돌아가기
315 : }
316 : } catch (e) {
317 0 : if (mounted) {
318 0 : setState(() {
319 0 : _isLoading = false;
320 : });
321 :
322 : // 테스트 환경에서 안전하게 SnackBar 표시
323 0 : if (TestUtils.isInTest) {
324 0 : debugPrint('이벤트 저장에 실패했습니다: $e');
325 : } else {
326 0 : ScaffoldMessenger.of(context).showSnackBar(
327 0 : SnackBar(content: Text('이벤트 저장에 실패했습니다: $e')),
328 : );
329 : }
330 : }
331 : }
332 : }
333 :
334 1 : @override
335 : Widget build(BuildContext context) {
336 : // 테스트 환경에서 애니메이션 비활성화
337 1 : final Widget scaffold = Scaffold(
338 1 : appBar: AppBar(
339 3 : title: Text(widget.event == null ? '새 이벤트 생성' : '이벤트 수정'),
340 1 : actions: [
341 1 : IconButton(
342 : icon: const Icon(Icons.save),
343 1 : onPressed: _saveEvent,
344 : ),
345 : ],
346 : ),
347 1 : body: _isLoading
348 : ? const LoadingIndicator()
349 1 : : SingleChildScrollView(
350 : padding: const EdgeInsets.all(16.0),
351 1 : child: Form(
352 1 : key: _formKey,
353 1 : child: Column(
354 : crossAxisAlignment: CrossAxisAlignment.start,
355 1 : children: [
356 : // 기본 정보 섹션
357 1 : _buildSectionTitle('기본 정보'),
358 :
359 : // 제목
360 1 : TextFormField(
361 1 : controller: _titleController,
362 1 : decoration: InputDecoration(
363 : labelText: '이벤트 제목 *',
364 : hintText: '이벤트 제목을 입력하세요',
365 : prefixIcon: const Icon(Icons.title),
366 : // 필수 필드 표시 강화
367 1 : labelStyle: TextStyle(
368 4 : color: _titleController.text.trim().isEmpty ? Colors.red : Colors.blue,
369 : fontWeight: FontWeight.bold,
370 : ),
371 : // 입력 상태에 따른 테두리 색상 변경
372 1 : enabledBorder: OutlineInputBorder(
373 1 : borderSide: BorderSide(
374 4 : color: _titleController.text.trim().isEmpty ? Colors.grey : Colors.green,
375 : width: 1.0,
376 : ),
377 : ),
378 : focusedBorder: const OutlineInputBorder(
379 : borderSide: BorderSide(
380 : color: Colors.blue,
381 : width: 2.0,
382 : ),
383 : ),
384 : // 오류 시 테두리 색상
385 : errorBorder: const OutlineInputBorder(
386 : borderSide: BorderSide(
387 : color: Colors.red,
388 : width: 1.0,
389 : ),
390 : ),
391 : // 입력 상태에 따른 도움말 텍스트
392 4 : helperText: _titleController.text.trim().isNotEmpty
393 : ? '유효한 제목입니다'
394 : : '이벤트의 제목을 입력해주세요 (필수)',
395 1 : helperStyle: TextStyle(
396 4 : color: _titleController.text.trim().isEmpty ? Colors.grey : Colors.green,
397 4 : fontStyle: _titleController.text.trim().isEmpty ? FontStyle.italic : FontStyle.normal,
398 : ),
399 : // 입력 완료 시 체크 아이콘 표시
400 4 : suffixIcon: _titleController.text.trim().isNotEmpty
401 : ? const Icon(Icons.check_circle, color: Colors.green)
402 : : null,
403 : ),
404 1 : validator: (value) {
405 2 : if (value == null || value.trim().isEmpty) {
406 : return '이벤트 제목을 입력해주세요';
407 : }
408 : return null;
409 : },
410 0 : onChanged: (value) {
411 : // 입력 변경 시 실시간으로 UI 업데이트
412 0 : setState(() {});
413 : },
414 : ),
415 : const SizedBox(height: 16),
416 :
417 : // 설명
418 1 : TextFormField(
419 1 : controller: _descriptionController,
420 : decoration: const InputDecoration(
421 : labelText: '이벤트 설명',
422 : hintText: '이벤트 설명을 입력하세요',
423 : ),
424 : maxLines: 3,
425 : ),
426 : const SizedBox(height: 16),
427 :
428 : // 이벤트 유형
429 1 : DropdownButtonFormField<String>(
430 1 : value: _type,
431 1 : decoration: InputDecoration(
432 : labelText: '이벤트 유형 *',
433 1 : labelStyle: TextStyle(
434 1 : color: _type == null ? Colors.red : Colors.blue,
435 : fontWeight: FontWeight.bold,
436 : ),
437 : prefixIcon: const Icon(Icons.category),
438 1 : helperText: _type != null
439 : ? '유효한 이벤트 유형입니다'
440 : : '이벤트 유형을 선택해주세요 (필수)',
441 1 : helperStyle: TextStyle(
442 1 : color: _type == null ? Colors.grey : Colors.green,
443 1 : fontStyle: _type == null ? FontStyle.italic : FontStyle.normal,
444 : ),
445 : // 입력 상태에 따른 테두리 색상 변경
446 1 : enabledBorder: OutlineInputBorder(
447 1 : borderSide: BorderSide(
448 1 : color: _type == null ? Colors.grey : Colors.green,
449 : width: 1.0,
450 : ),
451 : ),
452 : focusedBorder: const OutlineInputBorder(
453 : borderSide: BorderSide(
454 : color: Colors.blue,
455 : width: 2.0,
456 : ),
457 : ),
458 : // 오류 시 테두리 색상
459 : errorBorder: const OutlineInputBorder(
460 : borderSide: BorderSide(
461 : color: Colors.red,
462 : width: 1.0,
463 : ),
464 : ),
465 : ),
466 1 : items: _typeOptions
467 3 : .map((type) => DropdownMenuItem<String>(
468 : value: type,
469 1 : child: Row(
470 1 : children: [
471 1 : Icon(
472 2 : _typeIcons[type] ?? Icons.category,
473 2 : color: _typeColors[type],
474 : size: 18,
475 : ),
476 : const SizedBox(width: 8),
477 1 : Container(
478 : padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
479 1 : decoration: BoxDecoration(
480 3 : color: _typeColors[type]?.withOpacity(0.2),
481 1 : borderRadius: BorderRadius.circular(12),
482 3 : border: Border.all(color: _typeColors[type] ?? Colors.grey),
483 : ),
484 1 : child: Text(
485 : type,
486 3 : style: TextStyle(color: _typeColors[type]),
487 : ),
488 : ),
489 : ],
490 : ),
491 : ))
492 1 : .toList(),
493 0 : onChanged: (value) {
494 0 : setState(() {
495 0 : _type = value;
496 : });
497 : },
498 1 : validator: (value) {
499 1 : if (value == null || value.isEmpty) {
500 : return '이벤트 유형을 선택해주세요';
501 : }
502 : return null;
503 : },
504 : ),
505 : const SizedBox(height: 8),
506 1 : if (_type != null)
507 1 : Align(
508 : alignment: Alignment.centerLeft,
509 1 : child: Container(
510 : margin: const EdgeInsets.only(left: 12),
511 : padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
512 1 : decoration: BoxDecoration(
513 4 : color: _typeColors[_type]?.withOpacity(0.2),
514 1 : borderRadius: BorderRadius.circular(12),
515 4 : border: Border.all(color: _typeColors[_type] ?? Colors.grey),
516 1 : boxShadow: [
517 1 : BoxShadow(
518 1 : color: Colors.black.withOpacity(0.05),
519 : blurRadius: 2,
520 : offset: const Offset(0, 1),
521 : ),
522 : ],
523 : ),
524 1 : child: Row(
525 : mainAxisSize: MainAxisSize.min,
526 1 : children: [
527 1 : Icon(
528 3 : _typeIcons[_type] ?? Icons.category,
529 3 : color: _typeColors[_type],
530 : size: 14,
531 : ),
532 : const SizedBox(width: 4),
533 1 : Text(
534 1 : _type!,
535 1 : style: TextStyle(
536 3 : color: _typeColors[_type],
537 : fontSize: 12,
538 : fontWeight: FontWeight.bold,
539 : ),
540 : ),
541 : ],
542 : ),
543 : ),
544 : ),
545 1 : const SizedBox(height: 16),
546 :
547 1 : const SizedBox(height: 16),
548 :
549 : // 상태
550 1 : DropdownButtonFormField<String>(
551 1 : value: _status,
552 1 : decoration: InputDecoration(
553 : labelText: '상태 *',
554 1 : labelStyle: TextStyle(
555 1 : color: _status == null ? Colors.red : Colors.blue,
556 : fontWeight: FontWeight.bold,
557 : ),
558 : prefixIcon: const Icon(Icons.info_outline),
559 1 : helperText: _status != null
560 : ? '유효한 상태입니다'
561 : : '이벤트 상태를 선택해주세요 (필수)',
562 1 : helperStyle: TextStyle(
563 1 : color: _status == null ? Colors.grey : Colors.green,
564 1 : fontStyle: _status == null ? FontStyle.italic : FontStyle.normal,
565 : ),
566 : // 입력 상태에 따른 테두리 색상 변경
567 1 : enabledBorder: OutlineInputBorder(
568 1 : borderSide: BorderSide(
569 1 : color: _status == null ? Colors.grey : Colors.green,
570 : width: 1.0,
571 : ),
572 : ),
573 : focusedBorder: const OutlineInputBorder(
574 : borderSide: BorderSide(
575 : color: Colors.blue,
576 : width: 2.0,
577 : ),
578 : ),
579 : // 오류 시 테두리 색상
580 : errorBorder: const OutlineInputBorder(
581 : borderSide: BorderSide(
582 : color: Colors.red,
583 : width: 1.0,
584 : ),
585 : ),
586 : ),
587 1 : items: _statusOptions
588 3 : .map((status) => DropdownMenuItem<String>(
589 : value: status,
590 1 : child: Row(
591 1 : children: [
592 1 : Icon(
593 2 : _statusIcons[status] ?? Icons.info,
594 2 : color: _statusColors[status],
595 : size: 18,
596 : ),
597 : const SizedBox(width: 8),
598 1 : Container(
599 : padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
600 1 : decoration: BoxDecoration(
601 3 : color: _statusColors[status]?.withOpacity(0.2),
602 1 : borderRadius: BorderRadius.circular(12),
603 3 : border: Border.all(color: _statusColors[status] ?? Colors.grey),
604 : ),
605 1 : child: Text(
606 : status,
607 3 : style: TextStyle(color: _statusColors[status]),
608 : ),
609 : ),
610 : ],
611 : ),
612 : ))
613 1 : .toList(),
614 0 : onChanged: (value) {
615 0 : setState(() {
616 0 : _status = value;
617 : });
618 : },
619 1 : validator: (value) {
620 1 : if (value == null || value.isEmpty) {
621 : return '상태를 선택해주세요';
622 : }
623 : return null;
624 : },
625 : ),
626 1 : const SizedBox(height: 8),
627 1 : if (_status != null)
628 1 : Align(
629 : alignment: Alignment.centerLeft,
630 1 : child: Container(
631 : margin: const EdgeInsets.only(left: 12),
632 : padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
633 1 : decoration: BoxDecoration(
634 4 : color: _statusColors[_status]?.withOpacity(0.2),
635 1 : borderRadius: BorderRadius.circular(12),
636 4 : border: Border.all(color: _statusColors[_status] ?? Colors.grey),
637 1 : boxShadow: [
638 1 : BoxShadow(
639 1 : color: Colors.black.withOpacity(0.05),
640 : blurRadius: 2,
641 : offset: const Offset(0, 1),
642 : ),
643 : ],
644 : ),
645 1 : child: Row(
646 : mainAxisSize: MainAxisSize.min,
647 1 : children: [
648 1 : Icon(
649 3 : _statusIcons[_status] ?? Icons.info,
650 3 : color: _statusColors[_status],
651 : size: 14,
652 : ),
653 : const SizedBox(width: 4),
654 1 : Text(
655 1 : _status!,
656 1 : style: TextStyle(
657 3 : color: _statusColors[_status],
658 : fontSize: 12,
659 : fontWeight: FontWeight.bold,
660 : ),
661 : ),
662 : ],
663 : ),
664 : ),
665 : ),
666 1 : const SizedBox(height: 24),
667 :
668 : // 날짜 및 시간 섹션
669 1 : _buildSectionTitle('날짜 및 시간'),
670 :
671 : // 시작 날짜
672 1 : Container(
673 1 : decoration: BoxDecoration(
674 1 : border: Border.all(
675 : color: Colors.green,
676 : width: 1.0,
677 : ),
678 1 : borderRadius: BorderRadius.circular(4.0),
679 : ),
680 : margin: const EdgeInsets.symmetric(vertical: 8.0),
681 1 : child: ListTile(
682 1 : title: Row(
683 1 : children: [
684 : const Text('시작 날짜 및 시간 *',
685 : style: TextStyle(
686 : fontWeight: FontWeight.bold,
687 : color: Colors.blue,
688 : ),
689 : ),
690 : const SizedBox(width: 8),
691 : const Icon(Icons.check_circle, color: Colors.green, size: 16),
692 : ],
693 : ),
694 1 : subtitle: Column(
695 : crossAxisAlignment: CrossAxisAlignment.start,
696 1 : children: [
697 1 : Text(
698 3 : DateFormat('yyyy년 MM월 dd일 HH:mm').format(_startDate),
699 : style: const TextStyle(fontWeight: FontWeight.bold),
700 : ),
701 : const SizedBox(height: 4),
702 1 : Row(
703 1 : children: [
704 : const Icon(Icons.info_outline, size: 12, color: Colors.green),
705 : const SizedBox(width: 4),
706 : const Text('이벤트 시작 날짜와 시간을 설정해주세요 (필수)',
707 : style: TextStyle(fontSize: 12, fontStyle: FontStyle.italic, color: Colors.grey),
708 : ),
709 : ],
710 : ),
711 : ],
712 : ),
713 : leading: const Icon(Icons.event, color: Colors.blue),
714 : trailing: const Icon(Icons.calendar_today),
715 0 : onTap: () async {
716 0 : final date = await showDatePicker(
717 : context: context,
718 0 : initialDate: _startDate,
719 0 : firstDate: DateTime(2020),
720 0 : lastDate: DateTime(2030),
721 : );
722 :
723 : if (date != null) {
724 0 : final time = await showTimePicker(
725 : context: context,
726 0 : initialTime: TimeOfDay.fromDateTime(_startDate),
727 : );
728 :
729 0 : if (time != null && mounted) {
730 0 : setState(() {
731 0 : _startDate = DateTime(
732 0 : date.year,
733 0 : date.month,
734 0 : date.day,
735 0 : time.hour,
736 0 : time.minute,
737 : );
738 : });
739 : }
740 : }
741 : },
742 : ),
743 : ),
744 1 : const SizedBox(height: 8),
745 :
746 : // 종료 날짜
747 1 : ListTile(
748 : title: const Text('종료 날짜 및 시간 (선택)'),
749 1 : subtitle: _endDate != null
750 0 : ? Text(
751 0 : DateFormat('yyyy년 MM월 dd일 HH:mm').format(_endDate!),
752 : )
753 : : const Text('설정되지 않음'),
754 1 : trailing: Row(
755 : mainAxisSize: MainAxisSize.min,
756 1 : children: [
757 1 : if (_endDate != null)
758 0 : IconButton(
759 : icon: const Icon(Icons.clear),
760 0 : onPressed: () {
761 0 : setState(() {
762 0 : _endDate = null;
763 : });
764 : },
765 : ),
766 1 : const Icon(Icons.calendar_today),
767 : ],
768 : ),
769 0 : onTap: () async {
770 0 : final date = await showDatePicker(
771 : context: context,
772 0 : initialDate: _endDate ?? _startDate.add(const Duration(hours: 2)),
773 0 : firstDate: DateTime(2020),
774 0 : lastDate: DateTime(2030),
775 : );
776 :
777 : if (date != null) {
778 0 : final time = await showTimePicker(
779 : context: context,
780 0 : initialTime: _endDate != null
781 0 : ? TimeOfDay.fromDateTime(_endDate!)
782 0 : : TimeOfDay.fromDateTime(_startDate.add(const Duration(hours: 2))),
783 : );
784 :
785 0 : if (time != null && mounted) {
786 0 : setState(() {
787 0 : _endDate = DateTime(
788 0 : date.year,
789 0 : date.month,
790 0 : date.day,
791 0 : time.hour,
792 0 : time.minute,
793 : );
794 : });
795 : }
796 : }
797 : },
798 : ),
799 1 : const SizedBox(height: 8),
800 :
801 : // 등록 마감일
802 1 : ListTile(
803 : title: const Text('등록 마감일 (선택)'),
804 1 : subtitle: _registrationDeadline != null
805 0 : ? Text(
806 0 : DateFormat('yyyy년 MM월 dd일 HH:mm').format(_registrationDeadline!),
807 : )
808 : : const Text('설정되지 않음'),
809 1 : trailing: Row(
810 : mainAxisSize: MainAxisSize.min,
811 1 : children: [
812 1 : if (_registrationDeadline != null)
813 0 : IconButton(
814 : icon: const Icon(Icons.clear),
815 0 : onPressed: () {
816 0 : setState(() {
817 0 : _registrationDeadline = null;
818 : });
819 : },
820 : ),
821 1 : const Icon(Icons.calendar_today),
822 : ],
823 : ),
824 0 : onTap: () async {
825 0 : final date = await showDatePicker(
826 : context: context,
827 0 : initialDate: _registrationDeadline ?? _startDate.subtract(const Duration(days: 1)),
828 0 : firstDate: DateTime(2020),
829 0 : lastDate: DateTime(2030),
830 : );
831 :
832 : if (date != null) {
833 0 : final time = await showTimePicker(
834 : context: context,
835 0 : initialTime: _registrationDeadline != null
836 0 : ? TimeOfDay.fromDateTime(_registrationDeadline!)
837 0 : : TimeOfDay.fromDateTime(_startDate.subtract(const Duration(days: 1))),
838 : );
839 :
840 0 : if (time != null && mounted) {
841 0 : setState(() {
842 0 : _registrationDeadline = DateTime(
843 0 : date.year,
844 0 : date.month,
845 0 : date.day,
846 0 : time.hour,
847 0 : time.minute,
848 : );
849 : });
850 : }
851 : }
852 : },
853 : ),
854 1 : const SizedBox(height: 24),
855 :
856 : // 장소 및 참가자 섹션
857 1 : _buildSectionTitle('장소 및 참가자'),
858 :
859 : // 장소
860 1 : TextFormField(
861 1 : controller: _locationController,
862 : decoration: const InputDecoration(
863 : labelText: '장소',
864 : hintText: '이벤트 장소를 입력하세요',
865 : ),
866 : ),
867 1 : const SizedBox(height: 16),
868 :
869 : // 최대 참가자 수
870 1 : TextFormField(
871 1 : controller: _maxParticipantsController,
872 : decoration: const InputDecoration(
873 : labelText: '최대 참가자 수',
874 : hintText: '최대 참가자 수를 입력하세요',
875 : suffixText: '명',
876 : ),
877 : keyboardType: TextInputType.number,
878 1 : validator: (value) {
879 1 : if (value != null && value.isNotEmpty) {
880 0 : if (int.tryParse(value) == null) {
881 : return '숫자를 입력해주세요';
882 : }
883 : }
884 : return null;
885 : },
886 : ),
887 1 : const SizedBox(height: 16),
888 :
889 : // 게임 수
890 1 : TextFormField(
891 1 : controller: _gameCountController,
892 1 : decoration: InputDecoration(
893 : labelText: '게임 수',
894 : hintText: '게임 수를 입력하세요',
895 : suffixText: '게임',
896 : helperText: '이벤트에서 진행할 게임 수를 입력하세요',
897 2 : helperStyle: TextStyle(color: Colors.grey[600]),
898 : ),
899 : keyboardType: TextInputType.number,
900 1 : inputFormatters: [
901 1 : FilteringTextInputFormatter.digitsOnly,
902 : ],
903 1 : validator: (value) {
904 1 : if (value != null && value.isNotEmpty) {
905 0 : final numValue = int.tryParse(value);
906 : if (numValue == null) {
907 : return '유효한 숫자를 입력해주세요';
908 : }
909 0 : if (numValue <= 0) {
910 : return '1 이상의 숫자를 입력해주세요';
911 : }
912 0 : if (numValue > 100) {
913 : return '게임 수가 너무 많습니다 (100 이하)';
914 : }
915 : }
916 : return null;
917 : },
918 0 : onChanged: (value) {
919 : // 입력 중에도 화면 업데이트를 위해 setState 호출
920 0 : setState(() {});
921 : },
922 : ),
923 : // 게임 수가 유효한 경우 안내 메시지 표시
924 2 : Builder(builder: (context) {
925 3 : if (_gameCountController.text.isNotEmpty &&
926 0 : int.tryParse(_gameCountController.text) != null &&
927 0 : int.parse(_gameCountController.text) > 0) {
928 0 : return Padding(
929 : padding: const EdgeInsets.only(top: 4.0, left: 12.0),
930 0 : child: Text(
931 0 : '총 ${int.parse(_gameCountController.text)} 게임이 진행됩니다',
932 0 : style: TextStyle(color: Colors.green[700], fontSize: 12),
933 : ),
934 : );
935 : } else {
936 : return const SizedBox.shrink();
937 : }
938 : }),
939 1 : const SizedBox(height: 16),
940 :
941 : // 참가비
942 1 : Column(
943 : crossAxisAlignment: CrossAxisAlignment.start,
944 1 : children: [
945 : // 숨겨진 실제 참가비 입력 필드 (데이터 저장용)
946 1 : Offstage(
947 : offstage: true,
948 1 : child: TextFormField(
949 1 : controller: _participantFeeController,
950 : keyboardType: const TextInputType.numberWithOptions(decimal: true),
951 : ),
952 : ),
953 :
954 : // 포맷된 참가비 표시 필드 (사용자 입력용)
955 1 : TextFormField(
956 1 : controller: _formattedFeeController,
957 1 : decoration: InputDecoration(
958 : labelText: '참가비',
959 : hintText: '참가비를 입력하세요',
960 : prefixText: '₩',
961 1 : helperText: _getParticipantFeeHelperText(),
962 1 : helperStyle: TextStyle(
963 1 : color: _getParticipantFeeColor(),
964 3 : fontStyle: _participantFeeController.text.isEmpty ? FontStyle.italic : FontStyle.normal,
965 3 : fontWeight: _participantFeeController.text.isNotEmpty ? FontWeight.bold : FontWeight.normal,
966 : ),
967 : // 입력 상태에 따른 테두리 색상 변경
968 3 : enabledBorder: _participantFeeController.text.isEmpty
969 : ? null
970 1 : : OutlineInputBorder(
971 1 : borderSide: BorderSide(
972 1 : color: _getParticipantFeeColor(),
973 : width: 1.5,
974 : ),
975 : ),
976 3 : suffixIcon: _formattedFeeController.text.isNotEmpty
977 1 : ? IconButton(
978 : icon: const Icon(Icons.clear),
979 0 : onPressed: () {
980 0 : setState(() {
981 0 : _formattedFeeController.clear();
982 0 : _participantFeeController.clear();
983 : });
984 : },
985 : tooltip: '참가비 지우기',
986 : )
987 : : null,
988 : ),
989 : keyboardType: const TextInputType.numberWithOptions(decimal: false),
990 1 : inputFormatters: [
991 1 : FilteringTextInputFormatter.digitsOnly,
992 : ],
993 1 : validator: (value) {
994 1 : if (value != null && value.isNotEmpty) {
995 0 : final numValue = double.tryParse(_participantFeeController.text);
996 : if (numValue == null) {
997 : return '유효한 금액을 입력해주세요';
998 : }
999 0 : if (numValue < 0) {
1000 : return '0 이상의 금액을 입력해주세요';
1001 : }
1002 0 : if (numValue > 1000000) {
1003 : return '금액이 너무 큽니다 (최대 1,000,000원)';
1004 : }
1005 : }
1006 : return null;
1007 : },
1008 1 : onChanged: (value) {
1009 1 : if (value.isNotEmpty) {
1010 : // 콤마 제거 후 숫자만 추출
1011 2 : final plainNumber = value.replaceAll(RegExp(r'[^0-9]'), '');
1012 1 : final numericValue = int.tryParse(plainNumber);
1013 :
1014 : if (numericValue != null) {
1015 : // 원본 값 저장
1016 3 : _participantFeeController.text = numericValue.toString();
1017 :
1018 : // 포맷된 값 표시 (커서 위치 보존)
1019 3 : final cursorPosition = _formattedFeeController.selection.baseOffset;
1020 2 : final oldText = _formattedFeeController.text;
1021 2 : final newText = _currencyFormat.format(numericValue);
1022 :
1023 : // 커서 위치 계산 로직 개선
1024 : int newCursorPosition;
1025 1 : if (oldText.isEmpty) {
1026 0 : newCursorPosition = newText.length;
1027 : } else {
1028 : // 이전 텍스트와 새 텍스트의 길이 차이 계산
1029 3 : final lengthDiff = newText.length - oldText.length;
1030 3 : newCursorPosition = (cursorPosition + lengthDiff).clamp(0, newText.length);
1031 : }
1032 :
1033 3 : _formattedFeeController.value = TextEditingValue(
1034 : text: newText,
1035 1 : selection: TextSelection.collapsed(offset: newCursorPosition),
1036 : );
1037 : }
1038 : } else {
1039 0 : _participantFeeController.clear();
1040 : }
1041 :
1042 : // UI 업데이트를 위해 setState 호출
1043 2 : setState(() {});
1044 : },
1045 : ),
1046 :
1047 : // 참가비 입력 시 안내 메시지
1048 3 : if (_formattedFeeController.text.isNotEmpty)
1049 1 : Padding(
1050 : padding: const EdgeInsets.only(top: 4.0, left: 12.0),
1051 1 : child: Row(
1052 1 : children: [
1053 3 : Icon(_getParticipantFeeIcon(), size: 16, color: _getParticipantFeeColor()),
1054 : const SizedBox(width: 8),
1055 1 : Text(
1056 1 : _getParticipantFeeStatusText(),
1057 2 : style: TextStyle(color: _getParticipantFeeColor(), fontSize: 12, fontWeight: FontWeight.bold),
1058 : ),
1059 : ],
1060 : ),
1061 : ),
1062 : ],
1063 : ),
1064 1 : const SizedBox(height: 4),
1065 :
1066 1 : const SizedBox(height: 24),
1067 :
1068 : // 공개 접근 섹션
1069 1 : _buildSectionTitle('공개 접근'),
1070 :
1071 : // 공개 URL 해시
1072 1 : Column(
1073 : crossAxisAlignment: CrossAxisAlignment.start,
1074 1 : children: [
1075 1 : Row(
1076 1 : children: [
1077 1 : Expanded(
1078 1 : child: TextFormField(
1079 1 : controller: _publicHashController,
1080 1 : decoration: InputDecoration(
1081 : labelText: '공개 URL 해시',
1082 : hintText: '공개 URL 해시를 입력하세요',
1083 : helperText: '이벤트를 공개적으로 공유하기 위한 고유 식별자입니다',
1084 : ),
1085 : readOnly: true,
1086 : ),
1087 : ),
1088 1 : Tooltip(
1089 3 : message: _publicHashController.text.isEmpty ? '새 해시 생성' : '해시 재생성',
1090 1 : child: ElevatedButton.icon(
1091 : icon: const Icon(Icons.refresh, size: 18),
1092 4 : label: Text(_publicHashController.text.isEmpty ? '생성' : '재생성'),
1093 1 : onPressed: _generatePublicHash,
1094 1 : style: ElevatedButton.styleFrom(
1095 : backgroundColor: Colors.blue,
1096 : foregroundColor: Colors.white,
1097 : ),
1098 : ),
1099 : ),
1100 : ],
1101 : ),
1102 : const SizedBox(height: 8),
1103 3 : if (_publicHashController.text.isNotEmpty)
1104 1 : Container(
1105 : padding: const EdgeInsets.all(12),
1106 1 : decoration: BoxDecoration(
1107 1 : color: Colors.blue.withOpacity(0.1),
1108 1 : borderRadius: BorderRadius.circular(8),
1109 2 : border: Border.all(color: Colors.blue.withOpacity(0.3)),
1110 : ),
1111 1 : child: Column(
1112 : crossAxisAlignment: CrossAxisAlignment.start,
1113 1 : children: [
1114 : const Text('공개 URL:', style: TextStyle(fontWeight: FontWeight.bold)),
1115 : const SizedBox(height: 4),
1116 1 : Row(
1117 1 : children: [
1118 1 : Expanded(
1119 1 : child: Text(
1120 3 : 'https://bowling.example.com/events/${_publicHashController.text}',
1121 2 : style: TextStyle(color: Colors.blue[700]),
1122 : ),
1123 : ),
1124 1 : IconButton(
1125 : icon: const Icon(Icons.copy, color: Colors.blue),
1126 0 : onPressed: () {
1127 0 : final url = UrlUtils.getEventPublicUrl(_publicHashController.text);
1128 0 : UrlUtils.copyUrlToClipboard(context, url);
1129 : },
1130 : tooltip: 'URL 복사',
1131 : ),
1132 : ],
1133 : ),
1134 : const SizedBox(height: 4),
1135 : const Text('이 URL을 공유하면 누구나 이벤트를 볼 수 있습니다.',
1136 : style: TextStyle(fontSize: 12, fontStyle: FontStyle.italic)),
1137 : ],
1138 : ),
1139 : ),
1140 : ],
1141 : ),
1142 :
1143 1 : const SizedBox(height: 16),
1144 :
1145 : // 접근 비밀번호
1146 1 : Column(
1147 : crossAxisAlignment: CrossAxisAlignment.start,
1148 1 : children: [
1149 1 : TextFormField(
1150 1 : controller: _accessPasswordController,
1151 1 : decoration: InputDecoration(
1152 : labelText: '접근 비밀번호 (선택)',
1153 : hintText: '접근 비밀번호를 입력하세요',
1154 1 : helperText: _getPasswordHelperText(),
1155 1 : helperStyle: TextStyle(
1156 1 : color: _getPasswordStrengthColor(),
1157 3 : fontWeight: _accessPasswordController.text.isNotEmpty ? FontWeight.bold : FontWeight.normal,
1158 : ),
1159 : prefixIcon: const Icon(Icons.lock_outline),
1160 1 : suffixIcon: Row(
1161 : mainAxisSize: MainAxisSize.min,
1162 1 : children: [
1163 : // 비밀번호 표시/숨김 토글 버튼
1164 1 : IconButton(
1165 1 : icon: Icon(
1166 1 : _obscurePassword ? Icons.visibility_off : Icons.visibility,
1167 1 : color: _obscurePassword ? Colors.grey : Colors.blue,
1168 : ),
1169 0 : onPressed: () {
1170 0 : setState(() {
1171 0 : _obscurePassword = !_obscurePassword;
1172 : });
1173 : },
1174 1 : tooltip: _obscurePassword ? '비밀번호 표시' : '비밀번호 숨김',
1175 : ),
1176 : // 도움말 버튼
1177 1 : IconButton(
1178 : icon: const Icon(Icons.help_outline),
1179 0 : onPressed: () {
1180 0 : ScaffoldMessenger.of(context).showSnackBar(
1181 : const SnackBar(
1182 : content: Text('비밀번호를 설정하면 공개 URL로 접근하는 사용자도 비밀번호를 입력해야 합니다'),
1183 : duration: Duration(seconds: 5),
1184 : ),
1185 : );
1186 : },
1187 : tooltip: '비밀번호 도움말',
1188 : ),
1189 : ],
1190 : ),
1191 : // 비밀번호 강도에 따른 테두리 색상 변경
1192 3 : enabledBorder: _accessPasswordController.text.isEmpty
1193 : ? null
1194 0 : : OutlineInputBorder(
1195 0 : borderSide: BorderSide(
1196 0 : color: _getPasswordStrengthColor(),
1197 : width: 1.5,
1198 : ),
1199 : ),
1200 : ),
1201 1 : obscureText: _obscurePassword,
1202 0 : onChanged: (value) {
1203 : // 비밀번호 입력 시 실시간으로 UI 업데이트
1204 0 : setState(() {});
1205 : },
1206 1 : validator: (value) {
1207 1 : if (value != null && value.isNotEmpty && value.length < 4) {
1208 : return '비밀번호는 최소 4자 이상이어야 합니다';
1209 : }
1210 : return null;
1211 : },
1212 : ),
1213 3 : if (_accessPasswordController.text.isNotEmpty)
1214 0 : Padding(
1215 : padding: const EdgeInsets.only(top: 8.0, left: 12.0),
1216 0 : child: Row(
1217 0 : children: [
1218 0 : Icon(Icons.lock, size: 16, color: _getPasswordStrengthColor()),
1219 : const SizedBox(width: 8),
1220 0 : Text(
1221 : '비밀번호 보호가 활성화되었습니다',
1222 0 : style: TextStyle(
1223 0 : color: _getPasswordStrengthColor(),
1224 : fontSize: 12,
1225 : fontWeight: FontWeight.bold
1226 : ),
1227 : ),
1228 : ],
1229 : ),
1230 : ),
1231 : ],
1232 : ),
1233 1 : const SizedBox(height: 32),
1234 :
1235 : // 저장 버튼
1236 1 : SizedBox(
1237 : width: double.infinity,
1238 1 : child: ElevatedButton(
1239 1 : onPressed: _saveEvent,
1240 1 : style: ElevatedButton.styleFrom(
1241 : padding: const EdgeInsets.symmetric(vertical: 16),
1242 : ),
1243 1 : child: Text(
1244 2 : widget.event == null ? '이벤트 생성' : '이벤트 수정',
1245 : style: const TextStyle(fontSize: 16),
1246 : ),
1247 : ),
1248 : ),
1249 : ],
1250 : ),
1251 : ),
1252 : ),
1253 : );
1254 :
1255 : // 테스트 환경에서 애니메이션 비활성화
1256 1 : if (TestUtils.isInTest) {
1257 1 : return TestUtils.disableAnimations(child: scaffold);
1258 : }
1259 : return scaffold;
1260 : }
1261 :
1262 : // 섹션 제목 위젯
1263 1 : Widget _buildSectionTitle(String title) {
1264 1 : return Column(
1265 : crossAxisAlignment: CrossAxisAlignment.start,
1266 1 : children: [
1267 1 : Text(
1268 : title,
1269 : style: const TextStyle(
1270 : fontSize: 18,
1271 : fontWeight: FontWeight.bold,
1272 : ),
1273 : ),
1274 : const Divider(),
1275 : const SizedBox(height: 8),
1276 : ],
1277 : );
1278 : }
1279 :
1280 : // 비밀번호 강도에 따른 색상 반환
1281 1 : Color _getPasswordStrengthColor() {
1282 2 : final password = _accessPasswordController.text;
1283 :
1284 1 : if (password.isEmpty) {
1285 1 : return Colors.grey[600]!;
1286 : }
1287 :
1288 0 : if (password.length < 4) {
1289 0 : return Colors.red[700]!;
1290 0 : } else if (password.length < 6) {
1291 0 : return Colors.orange[700]!;
1292 0 : } else if (password.length < 8) {
1293 0 : return Colors.yellow[700]!;
1294 : } else {
1295 : // 8자 이상이고 숫자와 특수문자를 포함하는지 확인
1296 0 : bool hasNumber = password.contains(RegExp(r'[0-9]'));
1297 0 : bool hasSpecialChar = password.contains(RegExp(r'[!@#$%^&*(),.?":{}|<>]'));
1298 :
1299 : if (hasNumber && hasSpecialChar) {
1300 0 : return Colors.green[700]!;
1301 : } else {
1302 0 : return Colors.blue[700]!;
1303 : }
1304 : }
1305 : }
1306 :
1307 : // 비밀번호 강도에 따른 도움말 텍스트 반환
1308 1 : String _getPasswordHelperText() {
1309 2 : final password = _accessPasswordController.text;
1310 :
1311 1 : if (password.isEmpty) {
1312 : return '설정 시 이벤트 접근에 비밀번호가 필요합니다';
1313 : }
1314 :
1315 0 : if (password.length < 4) {
1316 : return '비밀번호가 너무 짧습니다 (최소 4자)';
1317 0 : } else if (password.length < 6) {
1318 : return '비밀번호 강도: 약함';
1319 0 : } else if (password.length < 8) {
1320 : return '비밀번호 강도: 보통';
1321 : } else {
1322 : // 8자 이상이고 숫자와 특수문자를 포함하는지 확인
1323 0 : bool hasNumber = password.contains(RegExp(r'[0-9]'));
1324 0 : bool hasSpecialChar = password.contains(RegExp(r'[!@#$%^&*(),.?":{}|<>]'));
1325 :
1326 : if (hasNumber && hasSpecialChar) {
1327 : return '비밀번호 강도: 매우 강함';
1328 : } else {
1329 : return '비밀번호 강도: 강함';
1330 : }
1331 : }
1332 : }
1333 :
1334 : // 참가비 포맷팅 업데이트
1335 1 : void _updateFormattedFee(String value) {
1336 1 : if (value.isEmpty) {
1337 0 : _formattedFeeController.clear();
1338 : return;
1339 : }
1340 :
1341 1 : final numericValue = double.tryParse(value);
1342 : if (numericValue != null) {
1343 : // 현재 커서 위치 저장
1344 3 : final currentCursor = _formattedFeeController.selection.baseOffset;
1345 2 : final oldText = _formattedFeeController.text;
1346 2 : final newText = _currencyFormat.format(numericValue);
1347 :
1348 : // 커서 위치 계산 로직 개선
1349 : int newCursorPosition = currentCursor;
1350 2 : if (oldText.isNotEmpty && currentCursor > 0) {
1351 : // 이전 텍스트와 새 텍스트의 길이 차이 계산
1352 3 : final lengthDiff = newText.length - oldText.length;
1353 3 : newCursorPosition = (currentCursor + lengthDiff).clamp(0, newText.length);
1354 : } else {
1355 0 : newCursorPosition = newText.length;
1356 : }
1357 :
1358 3 : _formattedFeeController.value = TextEditingValue(
1359 : text: newText,
1360 1 : selection: TextSelection.collapsed(offset: newCursorPosition),
1361 : );
1362 : }
1363 : }
1364 :
1365 : // 참가비 변경 리스너
1366 1 : void _onParticipantFeeChanged() {
1367 2 : final value = _participantFeeController.text;
1368 1 : if (value.isEmpty) {
1369 0 : if (_formattedFeeController.text.isNotEmpty) {
1370 0 : setState(() {
1371 0 : _formattedFeeController.clear();
1372 : });
1373 : }
1374 : } else {
1375 : // 값이 있으면 포맷팅 업데이트
1376 1 : _updateFormattedFee(value);
1377 : }
1378 : }
1379 :
1380 : // 참가비 입력 상태에 따른 도움말 텍스트 반환
1381 1 : String _getParticipantFeeHelperText() {
1382 3 : if (_participantFeeController.text.isEmpty) {
1383 : return '통화 형식으로 자동 변환됩니다';
1384 : }
1385 :
1386 3 : final fee = double.tryParse(_participantFeeController.text);
1387 : if (fee == null) {
1388 : return '유효한 금액을 입력해주세요';
1389 : }
1390 :
1391 1 : if (fee <= 0) {
1392 : return '참가비는 0원 이상이어야 합니다';
1393 1 : } else if (fee > 1000000) {
1394 : return '최대 금액은 1,000,000원입니다';
1395 1 : } else if (fee >= 100000) {
1396 0 : return '참가비: ${_currencyFormat.format(fee)}';
1397 : } else {
1398 3 : return '참가비: ${_currencyFormat.format(fee)}';
1399 : }
1400 : }
1401 :
1402 : // 참가비 입력 상태에 따른 색상 반환
1403 1 : Color _getParticipantFeeColor() {
1404 3 : if (_participantFeeController.text.isEmpty) {
1405 1 : return Colors.grey[600]!;
1406 : }
1407 :
1408 3 : final fee = double.tryParse(_participantFeeController.text);
1409 : if (fee == null) {
1410 0 : return Colors.red[700]!;
1411 : }
1412 :
1413 1 : if (fee <= 0) {
1414 0 : return Colors.red[700]!;
1415 1 : } else if (fee > 1000000) {
1416 0 : return Colors.orange[700]!;
1417 : } else {
1418 1 : return Colors.green[700]!;
1419 : }
1420 : }
1421 :
1422 : // 참가비 입력 상태에 따른 아이콘 반환
1423 1 : IconData _getParticipantFeeIcon() {
1424 3 : if (_participantFeeController.text.isEmpty) {
1425 : return Icons.info_outline;
1426 : }
1427 :
1428 3 : final fee = double.tryParse(_participantFeeController.text);
1429 1 : if (fee == null || fee <= 0) {
1430 : return Icons.error_outline;
1431 1 : } else if (fee > 1000000) {
1432 : return Icons.warning;
1433 : } else {
1434 : return Icons.check_circle;
1435 : }
1436 : }
1437 :
1438 : // 참가비 입력 상태에 따른 상태 텍스트 반환
1439 1 : String _getParticipantFeeStatusText() {
1440 3 : if (_participantFeeController.text.isEmpty) {
1441 : return '참가비가 설정되지 않았습니다';
1442 : }
1443 :
1444 3 : final fee = double.tryParse(_participantFeeController.text);
1445 : if (fee == null) {
1446 : return '유효하지 않은 금액입니다';
1447 : }
1448 :
1449 1 : if (fee <= 0) {
1450 : return '참가비는 0원 이상이어야 합니다';
1451 1 : } else if (fee > 1000000) {
1452 : return '최대 금액을 초과했습니다';
1453 : } else {
1454 : return '참가비가 설정되었습니다';
1455 : }
1456 : }
1457 : }
|