import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import 'package:intl/intl.dart'; import '../../utils/url_utils.dart'; import '../../utils/test_utils.dart'; import '../../models/event_model.dart'; import '../../services/auth_service.dart'; import '../../services/event_service.dart'; import '../../widgets/loading_indicator.dart'; class EventFormScreen extends StatefulWidget { final Event? event; // null이면 새 이벤트 생성, 아니면 이벤트 수정 const EventFormScreen({ Key? key, this.event, }) : super(key: key); @override State createState() => _EventFormScreenState(); } class _EventFormScreenState extends State { final _formKey = GlobalKey(); bool _isLoading = false; bool _isInit = false; bool _obscurePassword = true; // 비밀번호 숨김 상태 관리 final _currencyFormat = NumberFormat.currency(locale: 'ko_KR', symbol: '₩', decimalDigits: 0); // 폼 필드 컨트롤러 final TextEditingController _titleController = TextEditingController(); final TextEditingController _descriptionController = TextEditingController(); final TextEditingController _locationController = TextEditingController(); final TextEditingController _maxParticipantsController = TextEditingController(); final TextEditingController _gameCountController = TextEditingController(); final TextEditingController _participantFeeController = TextEditingController(); final TextEditingController _formattedFeeController = TextEditingController(); // 포맷된 참가비 표시용 final TextEditingController _publicHashController = TextEditingController(); final TextEditingController _accessPasswordController = TextEditingController(); // 이벤트 데이터 String? _type; String? _status; DateTime _startDate = DateTime.now(); DateTime? _endDate; DateTime? _registrationDeadline; // 이벤트 유형 및 상태 옵션 final List _typeOptions = ['개인전', '팀전', '리그', '토너먼트', '연습', '기타']; final List _statusOptions = ['활성', '대기', '취소', '완료']; // 이벤트 유형 및 상태별 색상 final Map _typeColors = { '개인전': Colors.blue, '팀전': Colors.green, '리그': Colors.purple, '토너먼트': Colors.orange, '연습': Colors.teal, '기타': Colors.grey, }; final Map _statusColors = { '활성': Colors.green, '대기': Colors.amber, '취소': Colors.red, '완료': Colors.blue, }; // 이벤트 유형 및 상태별 아이콘 final Map _typeIcons = { '개인전': Icons.person, '팀전': Icons.group, '리그': Icons.sports, '토너먼트': Icons.emoji_events, '연습': Icons.fitness_center, '기타': Icons.category, }; final Map _statusIcons = { '활성': Icons.check_circle, '대기': Icons.hourglass_empty, '취소': Icons.cancel, '완료': Icons.done_all, }; @override void initState() { super.initState(); if (widget.event != null) { // 기존 이벤트 수정 시 필드 초기화 _titleController.text = widget.event!.title; _descriptionController.text = widget.event!.description ?? ''; _locationController.text = widget.event!.location ?? ''; _maxParticipantsController.text = widget.event!.maxParticipants?.toString() ?? ''; _gameCountController.text = widget.event!.gameCount?.toString() ?? ''; _publicHashController.text = widget.event!.publicHash ?? ''; _accessPasswordController.text = widget.event!.accessPassword ?? ''; _type = widget.event!.type; _status = widget.event!.status; _startDate = widget.event!.startDate; _endDate = widget.event!.endDate; _registrationDeadline = widget.event!.registrationDeadline; // 참가비가 있으면 포맷된 참가비 컨트롤러 초기화 if (widget.event!.participantFee != null) { _updateFormattedFee(widget.event!.participantFee.toString()); } } else { // 새 이벤트 생성 시 기본값 설정 _type = _typeOptions[0]; _status = _statusOptions[0]; } // 참가비 입력 컨트롤러에 리스너 추가 _participantFeeController.addListener(_onParticipantFeeChanged); } bool _isHashGenerated = false; @override void didChangeDependencies() { super.didChangeDependencies(); // 새 이벤트 생성 시에만 해시 자동 생성 (한 번만 실행되도록 플래그 사용) if (widget.event == null && _publicHashController.text.isEmpty && !_isHashGenerated) { _isHashGenerated = true; // 다음 프레임에서 해시 생성 (context 사용 문제 방지) WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { _generatePublicHash(); } }); } } @override void dispose() { _titleController.dispose(); _descriptionController.dispose(); _locationController.dispose(); _maxParticipantsController.dispose(); _gameCountController.dispose(); _participantFeeController.removeListener(_onParticipantFeeChanged); _participantFeeController.dispose(); _formattedFeeController.dispose(); _publicHashController.dispose(); _accessPasswordController.dispose(); super.dispose(); } // 공개 URL 해시 생성 void _generatePublicHash() { // 이전 해시 저장 final previousHash = _publicHashController.text; final isNew = previousHash.isEmpty; // 새 해시 생성 final hash = UrlUtils.generatePublicHash(); setState(() { _publicHashController.text = hash; }); // 해시 생성 후 사용자에게 피드백 제공 // 테스트 환경에서는 로그만 출력하고, 일반 환경에서만 스낵바 표시 if (mounted) { if (TestUtils.isInTest) { debugPrint('해시 생성 완료: $hash'); } else { // 일반 환경에서는 다음 프레임에서 스낵바 표시 WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { try { final scaffoldMessenger = ScaffoldMessenger.of(context); final message = isNew ? '공개 URL 해시가 생성되었습니다' : '공개 URL 해시가 재생성되었습니다. 이전 URL은 더 이상 유효하지 않습니다'; scaffoldMessenger.showSnackBar( SnackBar( content: Text(message), action: SnackBarAction( label: 'URL 복사', onPressed: () { final url = UrlUtils.getEventPublicUrl(hash); UrlUtils.copyUrlToClipboard(context, url); }, ), ), ); } catch (e) { debugPrint('스낵바 표시 오류: $e'); } } }); } } } // 이벤트 저장 Future _saveEvent() async { final isValid = _formKey.currentState!.validate(); if (!isValid) { // 유효성 검사 실패 시 스낵바로 알림 if (mounted) { // 테스트 환경에서 안전하게 SnackBar 표시 if (TestUtils.isInTest) { debugPrint('유효성 검사 실패: 필수 입력 항목을 모두 작성해주세요'); } else { // 일반 환경에서는 SnackBar 표시 WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { final scaffoldMessenger = ScaffoldMessenger.of(context); scaffoldMessenger.hideCurrentSnackBar(); scaffoldMessenger.showSnackBar( const SnackBar( content: Text('필수 입력 항목을 모두 작성해주세요 (제목, 이벤트 유형, 상태, 시작 날짜)'), backgroundColor: Colors.red, duration: Duration(seconds: 3), ), ); } }); } } return; } setState(() { _isLoading = true; }); try { final eventService = Provider.of(context, listen: false); final authService = Provider.of(context, listen: false); // 이벤트 데이터 준비 - null 값 처리 개선 final eventData = { 'title': _titleController.text.trim(), 'description': _descriptionController.text.trim().isEmpty ? null : _descriptionController.text.trim(), 'location': _locationController.text.trim().isEmpty ? null : _locationController.text.trim(), 'type': _type, 'status': _status, 'startDate': _startDate.toIso8601String(), 'endDate': _endDate?.toIso8601String(), 'maxParticipants': _maxParticipantsController.text.isEmpty ? null : int.parse(_maxParticipantsController.text), 'gameCount': _gameCountController.text.isEmpty ? null : int.parse(_gameCountController.text), 'participantFee': _participantFeeController.text.isEmpty ? null : double.parse(_participantFeeController.text), 'registrationDeadline': _registrationDeadline?.toIso8601String(), 'publicHash': _publicHashController.text.trim().isEmpty ? null : _publicHashController.text.trim(), 'accessPassword': _accessPasswordController.text.trim().isEmpty ? null : _accessPasswordController.text.trim(), }; // 서버에 명시적으로 null 값을 전송하기 위해 빈 문자열을 null로 변환 // JavaScript에서 || 연산자로 인한 문제 방지 eventData.forEach((key, value) { if (value is String && value.isEmpty) { eventData[key] = null; } }); // 디버그용 로그 - 개발 완료 후 제거 필요 debugPrint('Event data to send: $eventData'); if (widget.event == null) { // 새 이벤트 생성 await eventService.createEvent(eventData); if (mounted) { // 테스트 환경에서 안전하게 SnackBar 표시 if (TestUtils.isInTest) { debugPrint('이벤트가 생성되었습니다'); } else { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: const Text('이벤트가 생성되었습니다'), action: SnackBarAction( label: '확인', onPressed: () {}, ), duration: const Duration(seconds: 3), ), ); } } } else { // 기존 이벤트 수정 await eventService.updateEvent(widget.event!.id, eventData); if (mounted) { // 테스트 환경에서 안전하게 SnackBar 표시 if (TestUtils.isInTest) { debugPrint('이벤트가 수정되었습니다'); } else { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: const Text('이벤트가 수정되었습니다'), action: SnackBarAction( label: '확인', onPressed: () {}, ), duration: const Duration(seconds: 3), ), ); } } } if (mounted) { Navigator.of(context).pop(true); // 성공 결과와 함께 이전 화면으로 돌아가기 } } catch (e) { if (mounted) { setState(() { _isLoading = false; }); // 테스트 환경에서 안전하게 SnackBar 표시 if (TestUtils.isInTest) { debugPrint('이벤트 저장에 실패했습니다: $e'); } else { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('이벤트 저장에 실패했습니다: $e')), ); } } } } @override Widget build(BuildContext context) { // 테스트 환경에서 애니메이션 비활성화 final Widget scaffold = Scaffold( appBar: AppBar( title: Text(widget.event == null ? '새 이벤트 생성' : '이벤트 수정'), actions: [ IconButton( icon: const Icon(Icons.save), onPressed: _saveEvent, ), ], ), body: _isLoading ? const LoadingIndicator() : SingleChildScrollView( padding: const EdgeInsets.all(16.0), child: Form( key: _formKey, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 기본 정보 섹션 _buildSectionTitle('기본 정보'), // 제목 TextFormField( controller: _titleController, decoration: InputDecoration( labelText: '이벤트 제목 *', hintText: '이벤트 제목을 입력하세요', prefixIcon: const Icon(Icons.title), // 필수 필드 표시 강화 labelStyle: TextStyle( color: _titleController.text.trim().isEmpty ? Colors.red : Colors.blue, fontWeight: FontWeight.bold, ), // 입력 상태에 따른 테두리 색상 변경 enabledBorder: OutlineInputBorder( borderSide: BorderSide( color: _titleController.text.trim().isEmpty ? Colors.grey : Colors.green, width: 1.0, ), ), focusedBorder: const OutlineInputBorder( borderSide: BorderSide( color: Colors.blue, width: 2.0, ), ), // 오류 시 테두리 색상 errorBorder: const OutlineInputBorder( borderSide: BorderSide( color: Colors.red, width: 1.0, ), ), // 입력 상태에 따른 도움말 텍스트 helperText: _titleController.text.trim().isNotEmpty ? '유효한 제목입니다' : '이벤트의 제목을 입력해주세요 (필수)', helperStyle: TextStyle( color: _titleController.text.trim().isEmpty ? Colors.grey : Colors.green, fontStyle: _titleController.text.trim().isEmpty ? FontStyle.italic : FontStyle.normal, ), // 입력 완료 시 체크 아이콘 표시 suffixIcon: _titleController.text.trim().isNotEmpty ? const Icon(Icons.check_circle, color: Colors.green) : null, ), validator: (value) { if (value == null || value.trim().isEmpty) { return '이벤트 제목을 입력해주세요'; } return null; }, onChanged: (value) { // 입력 변경 시 실시간으로 UI 업데이트 setState(() {}); }, ), const SizedBox(height: 16), // 설명 TextFormField( controller: _descriptionController, decoration: const InputDecoration( labelText: '이벤트 설명', hintText: '이벤트 설명을 입력하세요', ), maxLines: 3, ), const SizedBox(height: 16), // 이벤트 유형 DropdownButtonFormField( value: _type, decoration: InputDecoration( labelText: '이벤트 유형 *', labelStyle: TextStyle( color: _type == null ? Colors.red : Colors.blue, fontWeight: FontWeight.bold, ), prefixIcon: const Icon(Icons.category), helperText: _type != null ? '유효한 이벤트 유형입니다' : '이벤트 유형을 선택해주세요 (필수)', helperStyle: TextStyle( color: _type == null ? Colors.grey : Colors.green, fontStyle: _type == null ? FontStyle.italic : FontStyle.normal, ), // 입력 상태에 따른 테두리 색상 변경 enabledBorder: OutlineInputBorder( borderSide: BorderSide( color: _type == null ? Colors.grey : Colors.green, width: 1.0, ), ), focusedBorder: const OutlineInputBorder( borderSide: BorderSide( color: Colors.blue, width: 2.0, ), ), // 오류 시 테두리 색상 errorBorder: const OutlineInputBorder( borderSide: BorderSide( color: Colors.red, width: 1.0, ), ), ), items: _typeOptions .map((type) => DropdownMenuItem( value: type, child: Row( children: [ Icon( _typeIcons[type] ?? Icons.category, color: _typeColors[type], size: 18, ), const SizedBox(width: 8), Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration( color: _typeColors[type]?.withOpacity(0.2), borderRadius: BorderRadius.circular(12), border: Border.all(color: _typeColors[type] ?? Colors.grey), ), child: Text( type, style: TextStyle(color: _typeColors[type]), ), ), ], ), )) .toList(), onChanged: (value) { setState(() { _type = value; }); }, validator: (value) { if (value == null || value.isEmpty) { return '이벤트 유형을 선택해주세요'; } return null; }, ), const SizedBox(height: 8), if (_type != null) Align( alignment: Alignment.centerLeft, child: Container( margin: const EdgeInsets.only(left: 12), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: _typeColors[_type]?.withOpacity(0.2), borderRadius: BorderRadius.circular(12), border: Border.all(color: _typeColors[_type] ?? Colors.grey), boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.05), blurRadius: 2, offset: const Offset(0, 1), ), ], ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( _typeIcons[_type] ?? Icons.category, color: _typeColors[_type], size: 14, ), const SizedBox(width: 4), Text( _type!, style: TextStyle( color: _typeColors[_type], fontSize: 12, fontWeight: FontWeight.bold, ), ), ], ), ), ), const SizedBox(height: 16), const SizedBox(height: 16), // 상태 DropdownButtonFormField( value: _status, decoration: InputDecoration( labelText: '상태 *', labelStyle: TextStyle( color: _status == null ? Colors.red : Colors.blue, fontWeight: FontWeight.bold, ), prefixIcon: const Icon(Icons.info_outline), helperText: _status != null ? '유효한 상태입니다' : '이벤트 상태를 선택해주세요 (필수)', helperStyle: TextStyle( color: _status == null ? Colors.grey : Colors.green, fontStyle: _status == null ? FontStyle.italic : FontStyle.normal, ), // 입력 상태에 따른 테두리 색상 변경 enabledBorder: OutlineInputBorder( borderSide: BorderSide( color: _status == null ? Colors.grey : Colors.green, width: 1.0, ), ), focusedBorder: const OutlineInputBorder( borderSide: BorderSide( color: Colors.blue, width: 2.0, ), ), // 오류 시 테두리 색상 errorBorder: const OutlineInputBorder( borderSide: BorderSide( color: Colors.red, width: 1.0, ), ), ), items: _statusOptions .map((status) => DropdownMenuItem( value: status, child: Row( children: [ Icon( _statusIcons[status] ?? Icons.info, color: _statusColors[status], size: 18, ), const SizedBox(width: 8), Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration( color: _statusColors[status]?.withOpacity(0.2), borderRadius: BorderRadius.circular(12), border: Border.all(color: _statusColors[status] ?? Colors.grey), ), child: Text( status, style: TextStyle(color: _statusColors[status]), ), ), ], ), )) .toList(), onChanged: (value) { setState(() { _status = value; }); }, validator: (value) { if (value == null || value.isEmpty) { return '상태를 선택해주세요'; } return null; }, ), const SizedBox(height: 8), if (_status != null) Align( alignment: Alignment.centerLeft, child: Container( margin: const EdgeInsets.only(left: 12), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: _statusColors[_status]?.withOpacity(0.2), borderRadius: BorderRadius.circular(12), border: Border.all(color: _statusColors[_status] ?? Colors.grey), boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.05), blurRadius: 2, offset: const Offset(0, 1), ), ], ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( _statusIcons[_status] ?? Icons.info, color: _statusColors[_status], size: 14, ), const SizedBox(width: 4), Text( _status!, style: TextStyle( color: _statusColors[_status], fontSize: 12, fontWeight: FontWeight.bold, ), ), ], ), ), ), const SizedBox(height: 24), // 날짜 및 시간 섹션 _buildSectionTitle('날짜 및 시간'), // 시작 날짜 Container( decoration: BoxDecoration( border: Border.all( color: Colors.green, width: 1.0, ), borderRadius: BorderRadius.circular(4.0), ), margin: const EdgeInsets.symmetric(vertical: 8.0), child: ListTile( title: Row( children: [ const Text('시작 날짜 및 시간 *', style: TextStyle( fontWeight: FontWeight.bold, color: Colors.blue, ), ), const SizedBox(width: 8), const Icon(Icons.check_circle, color: Colors.green, size: 16), ], ), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( DateFormat('yyyy년 MM월 dd일 HH:mm').format(_startDate), style: const TextStyle(fontWeight: FontWeight.bold), ), const SizedBox(height: 4), Row( children: [ const Icon(Icons.info_outline, size: 12, color: Colors.green), const SizedBox(width: 4), const Text('이벤트 시작 날짜와 시간을 설정해주세요 (필수)', style: TextStyle(fontSize: 12, fontStyle: FontStyle.italic, color: Colors.grey), ), ], ), ], ), leading: const Icon(Icons.event, color: Colors.blue), trailing: const Icon(Icons.calendar_today), onTap: () async { final date = await showDatePicker( context: context, initialDate: _startDate, firstDate: DateTime(2020), lastDate: DateTime(2030), ); if (date != null) { final time = await showTimePicker( context: context, initialTime: TimeOfDay.fromDateTime(_startDate), ); if (time != null && mounted) { setState(() { _startDate = DateTime( date.year, date.month, date.day, time.hour, time.minute, ); }); } } }, ), ), const SizedBox(height: 8), // 종료 날짜 ListTile( title: const Text('종료 날짜 및 시간 (선택)'), subtitle: _endDate != null ? Text( DateFormat('yyyy년 MM월 dd일 HH:mm').format(_endDate!), ) : const Text('설정되지 않음'), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ if (_endDate != null) IconButton( icon: const Icon(Icons.clear), onPressed: () { setState(() { _endDate = null; }); }, ), const Icon(Icons.calendar_today), ], ), onTap: () async { final date = await showDatePicker( context: context, initialDate: _endDate ?? _startDate.add(const Duration(hours: 2)), firstDate: DateTime(2020), lastDate: DateTime(2030), ); if (date != null) { final time = await showTimePicker( context: context, initialTime: _endDate != null ? TimeOfDay.fromDateTime(_endDate!) : TimeOfDay.fromDateTime(_startDate.add(const Duration(hours: 2))), ); if (time != null && mounted) { setState(() { _endDate = DateTime( date.year, date.month, date.day, time.hour, time.minute, ); }); } } }, ), const SizedBox(height: 8), // 등록 마감일 ListTile( title: const Text('등록 마감일 (선택)'), subtitle: _registrationDeadline != null ? Text( DateFormat('yyyy년 MM월 dd일 HH:mm').format(_registrationDeadline!), ) : const Text('설정되지 않음'), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ if (_registrationDeadline != null) IconButton( icon: const Icon(Icons.clear), onPressed: () { setState(() { _registrationDeadline = null; }); }, ), const Icon(Icons.calendar_today), ], ), onTap: () async { final date = await showDatePicker( context: context, initialDate: _registrationDeadline ?? _startDate.subtract(const Duration(days: 1)), firstDate: DateTime(2020), lastDate: DateTime(2030), ); if (date != null) { final time = await showTimePicker( context: context, initialTime: _registrationDeadline != null ? TimeOfDay.fromDateTime(_registrationDeadline!) : TimeOfDay.fromDateTime(_startDate.subtract(const Duration(days: 1))), ); if (time != null && mounted) { setState(() { _registrationDeadline = DateTime( date.year, date.month, date.day, time.hour, time.minute, ); }); } } }, ), const SizedBox(height: 24), // 장소 및 참가자 섹션 _buildSectionTitle('장소 및 참가자'), // 장소 TextFormField( controller: _locationController, decoration: const InputDecoration( labelText: '장소', hintText: '이벤트 장소를 입력하세요', ), ), const SizedBox(height: 16), // 최대 참가자 수 TextFormField( controller: _maxParticipantsController, decoration: const InputDecoration( labelText: '최대 참가자 수', hintText: '최대 참가자 수를 입력하세요', suffixText: '명', ), keyboardType: TextInputType.number, validator: (value) { if (value != null && value.isNotEmpty) { if (int.tryParse(value) == null) { return '숫자를 입력해주세요'; } } return null; }, ), const SizedBox(height: 16), // 게임 수 TextFormField( controller: _gameCountController, decoration: InputDecoration( labelText: '게임 수', hintText: '게임 수를 입력하세요', suffixText: '게임', helperText: '이벤트에서 진행할 게임 수를 입력하세요', helperStyle: TextStyle(color: Colors.grey[600]), ), keyboardType: TextInputType.number, inputFormatters: [ FilteringTextInputFormatter.digitsOnly, ], validator: (value) { if (value != null && value.isNotEmpty) { final numValue = int.tryParse(value); if (numValue == null) { return '유효한 숫자를 입력해주세요'; } if (numValue <= 0) { return '1 이상의 숫자를 입력해주세요'; } if (numValue > 100) { return '게임 수가 너무 많습니다 (100 이하)'; } } return null; }, onChanged: (value) { // 입력 중에도 화면 업데이트를 위해 setState 호출 setState(() {}); }, ), // 게임 수가 유효한 경우 안내 메시지 표시 Builder(builder: (context) { if (_gameCountController.text.isNotEmpty && int.tryParse(_gameCountController.text) != null && int.parse(_gameCountController.text) > 0) { return Padding( padding: const EdgeInsets.only(top: 4.0, left: 12.0), child: Text( '총 ${int.parse(_gameCountController.text)} 게임이 진행됩니다', style: TextStyle(color: Colors.green[700], fontSize: 12), ), ); } else { return const SizedBox.shrink(); } }), const SizedBox(height: 16), // 참가비 Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 숨겨진 실제 참가비 입력 필드 (데이터 저장용) Offstage( offstage: true, child: TextFormField( controller: _participantFeeController, keyboardType: const TextInputType.numberWithOptions(decimal: true), ), ), // 포맷된 참가비 표시 필드 (사용자 입력용) TextFormField( controller: _formattedFeeController, decoration: InputDecoration( labelText: '참가비', hintText: '참가비를 입력하세요', prefixText: '₩', helperText: _getParticipantFeeHelperText(), helperStyle: TextStyle( color: _getParticipantFeeColor(), fontStyle: _participantFeeController.text.isEmpty ? FontStyle.italic : FontStyle.normal, fontWeight: _participantFeeController.text.isNotEmpty ? FontWeight.bold : FontWeight.normal, ), // 입력 상태에 따른 테두리 색상 변경 enabledBorder: _participantFeeController.text.isEmpty ? null : OutlineInputBorder( borderSide: BorderSide( color: _getParticipantFeeColor(), width: 1.5, ), ), suffixIcon: _formattedFeeController.text.isNotEmpty ? IconButton( icon: const Icon(Icons.clear), onPressed: () { setState(() { _formattedFeeController.clear(); _participantFeeController.clear(); }); }, tooltip: '참가비 지우기', ) : null, ), keyboardType: const TextInputType.numberWithOptions(decimal: false), inputFormatters: [ FilteringTextInputFormatter.digitsOnly, ], validator: (value) { if (value != null && value.isNotEmpty) { final numValue = double.tryParse(_participantFeeController.text); if (numValue == null) { return '유효한 금액을 입력해주세요'; } if (numValue < 0) { return '0 이상의 금액을 입력해주세요'; } if (numValue > 1000000) { return '금액이 너무 큽니다 (최대 1,000,000원)'; } } return null; }, onChanged: (value) { if (value.isNotEmpty) { // 콤마 제거 후 숫자만 추출 final plainNumber = value.replaceAll(RegExp(r'[^0-9]'), ''); final numericValue = int.tryParse(plainNumber); if (numericValue != null) { // 원본 값 저장 _participantFeeController.text = numericValue.toString(); // 포맷된 값 표시 (커서 위치 보존) final cursorPosition = _formattedFeeController.selection.baseOffset; final oldText = _formattedFeeController.text; final newText = _currencyFormat.format(numericValue); // 커서 위치 계산 로직 개선 int newCursorPosition; if (oldText.isEmpty) { newCursorPosition = newText.length; } else { // 이전 텍스트와 새 텍스트의 길이 차이 계산 final lengthDiff = newText.length - oldText.length; newCursorPosition = (cursorPosition + lengthDiff).clamp(0, newText.length); } _formattedFeeController.value = TextEditingValue( text: newText, selection: TextSelection.collapsed(offset: newCursorPosition), ); } } else { _participantFeeController.clear(); } // UI 업데이트를 위해 setState 호출 setState(() {}); }, ), // 참가비 입력 시 안내 메시지 if (_formattedFeeController.text.isNotEmpty) Padding( padding: const EdgeInsets.only(top: 4.0, left: 12.0), child: Row( children: [ Icon(_getParticipantFeeIcon(), size: 16, color: _getParticipantFeeColor()), const SizedBox(width: 8), Text( _getParticipantFeeStatusText(), style: TextStyle(color: _getParticipantFeeColor(), fontSize: 12, fontWeight: FontWeight.bold), ), ], ), ), ], ), const SizedBox(height: 4), const SizedBox(height: 24), // 공개 접근 섹션 _buildSectionTitle('공개 접근'), // 공개 URL 해시 Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: TextFormField( controller: _publicHashController, decoration: InputDecoration( labelText: '공개 URL 해시', hintText: '공개 URL 해시를 입력하세요', helperText: '이벤트를 공개적으로 공유하기 위한 고유 식별자입니다', ), readOnly: true, ), ), Tooltip( message: _publicHashController.text.isEmpty ? '새 해시 생성' : '해시 재생성', child: ElevatedButton.icon( icon: const Icon(Icons.refresh, size: 18), label: Text(_publicHashController.text.isEmpty ? '생성' : '재생성'), onPressed: _generatePublicHash, style: ElevatedButton.styleFrom( backgroundColor: Colors.blue, foregroundColor: Colors.white, ), ), ), ], ), const SizedBox(height: 8), if (_publicHashController.text.isNotEmpty) Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: Colors.blue.withOpacity(0.1), borderRadius: BorderRadius.circular(8), border: Border.all(color: Colors.blue.withOpacity(0.3)), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text('공개 URL:', style: TextStyle(fontWeight: FontWeight.bold)), const SizedBox(height: 4), Row( children: [ Expanded( child: Text( 'https://bowling.example.com/events/${_publicHashController.text}', style: TextStyle(color: Colors.blue[700]), ), ), IconButton( icon: const Icon(Icons.copy, color: Colors.blue), onPressed: () { final url = UrlUtils.getEventPublicUrl(_publicHashController.text); UrlUtils.copyUrlToClipboard(context, url); }, tooltip: 'URL 복사', ), ], ), const SizedBox(height: 4), const Text('이 URL을 공유하면 누구나 이벤트를 볼 수 있습니다.', style: TextStyle(fontSize: 12, fontStyle: FontStyle.italic)), ], ), ), ], ), const SizedBox(height: 16), // 접근 비밀번호 Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ TextFormField( controller: _accessPasswordController, decoration: InputDecoration( labelText: '접근 비밀번호 (선택)', hintText: '접근 비밀번호를 입력하세요', helperText: _getPasswordHelperText(), helperStyle: TextStyle( color: _getPasswordStrengthColor(), fontWeight: _accessPasswordController.text.isNotEmpty ? FontWeight.bold : FontWeight.normal, ), prefixIcon: const Icon(Icons.lock_outline), suffixIcon: Row( mainAxisSize: MainAxisSize.min, children: [ // 비밀번호 표시/숨김 토글 버튼 IconButton( icon: Icon( _obscurePassword ? Icons.visibility_off : Icons.visibility, color: _obscurePassword ? Colors.grey : Colors.blue, ), onPressed: () { setState(() { _obscurePassword = !_obscurePassword; }); }, tooltip: _obscurePassword ? '비밀번호 표시' : '비밀번호 숨김', ), // 도움말 버튼 IconButton( icon: const Icon(Icons.help_outline), onPressed: () { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('비밀번호를 설정하면 공개 URL로 접근하는 사용자도 비밀번호를 입력해야 합니다'), duration: Duration(seconds: 5), ), ); }, tooltip: '비밀번호 도움말', ), ], ), // 비밀번호 강도에 따른 테두리 색상 변경 enabledBorder: _accessPasswordController.text.isEmpty ? null : OutlineInputBorder( borderSide: BorderSide( color: _getPasswordStrengthColor(), width: 1.5, ), ), ), obscureText: _obscurePassword, onChanged: (value) { // 비밀번호 입력 시 실시간으로 UI 업데이트 setState(() {}); }, validator: (value) { if (value != null && value.isNotEmpty && value.length < 4) { return '비밀번호는 최소 4자 이상이어야 합니다'; } return null; }, ), if (_accessPasswordController.text.isNotEmpty) Padding( padding: const EdgeInsets.only(top: 8.0, left: 12.0), child: Row( children: [ Icon(Icons.lock, size: 16, color: _getPasswordStrengthColor()), const SizedBox(width: 8), Text( '비밀번호 보호가 활성화되었습니다', style: TextStyle( color: _getPasswordStrengthColor(), fontSize: 12, fontWeight: FontWeight.bold ), ), ], ), ), ], ), const SizedBox(height: 32), // 저장 버튼 SizedBox( width: double.infinity, child: ElevatedButton( onPressed: _saveEvent, style: ElevatedButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 16), ), child: Text( widget.event == null ? '이벤트 생성' : '이벤트 수정', style: const TextStyle(fontSize: 16), ), ), ), ], ), ), ), ); // 테스트 환경에서 애니메이션 비활성화 if (TestUtils.isInTest) { return TestUtils.disableAnimations(child: scaffold); } return scaffold; } // 섹션 제목 위젯 Widget _buildSectionTitle(String title) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( title, style: const TextStyle( fontSize: 18, fontWeight: FontWeight.bold, ), ), const Divider(), const SizedBox(height: 8), ], ); } // 비밀번호 강도에 따른 색상 반환 Color _getPasswordStrengthColor() { final password = _accessPasswordController.text; if (password.isEmpty) { return Colors.grey[600]!; } if (password.length < 4) { return Colors.red[700]!; } else if (password.length < 6) { return Colors.orange[700]!; } else if (password.length < 8) { return Colors.yellow[700]!; } else { // 8자 이상이고 숫자와 특수문자를 포함하는지 확인 bool hasNumber = password.contains(RegExp(r'[0-9]')); bool hasSpecialChar = password.contains(RegExp(r'[!@#$%^&*(),.?":{}|<>]')); if (hasNumber && hasSpecialChar) { return Colors.green[700]!; } else { return Colors.blue[700]!; } } } // 비밀번호 강도에 따른 도움말 텍스트 반환 String _getPasswordHelperText() { final password = _accessPasswordController.text; if (password.isEmpty) { return '설정 시 이벤트 접근에 비밀번호가 필요합니다'; } if (password.length < 4) { return '비밀번호가 너무 짧습니다 (최소 4자)'; } else if (password.length < 6) { return '비밀번호 강도: 약함'; } else if (password.length < 8) { return '비밀번호 강도: 보통'; } else { // 8자 이상이고 숫자와 특수문자를 포함하는지 확인 bool hasNumber = password.contains(RegExp(r'[0-9]')); bool hasSpecialChar = password.contains(RegExp(r'[!@#$%^&*(),.?":{}|<>]')); if (hasNumber && hasSpecialChar) { return '비밀번호 강도: 매우 강함'; } else { return '비밀번호 강도: 강함'; } } } // 참가비 포맷팅 업데이트 void _updateFormattedFee(String value) { if (value.isEmpty) { _formattedFeeController.clear(); return; } final numericValue = double.tryParse(value); if (numericValue != null) { // 현재 커서 위치 저장 final currentCursor = _formattedFeeController.selection.baseOffset; final oldText = _formattedFeeController.text; final newText = _currencyFormat.format(numericValue); // 커서 위치 계산 로직 개선 int newCursorPosition = currentCursor; if (oldText.isNotEmpty && currentCursor > 0) { // 이전 텍스트와 새 텍스트의 길이 차이 계산 final lengthDiff = newText.length - oldText.length; newCursorPosition = (currentCursor + lengthDiff).clamp(0, newText.length); } else { newCursorPosition = newText.length; } _formattedFeeController.value = TextEditingValue( text: newText, selection: TextSelection.collapsed(offset: newCursorPosition), ); } } // 참가비 변경 리스너 void _onParticipantFeeChanged() { final value = _participantFeeController.text; if (value.isEmpty) { if (_formattedFeeController.text.isNotEmpty) { setState(() { _formattedFeeController.clear(); }); } } else { // 값이 있으면 포맷팅 업데이트 _updateFormattedFee(value); } } // 참가비 입력 상태에 따른 도움말 텍스트 반환 String _getParticipantFeeHelperText() { if (_participantFeeController.text.isEmpty) { return '통화 형식으로 자동 변환됩니다'; } final fee = double.tryParse(_participantFeeController.text); if (fee == null) { return '유효한 금액을 입력해주세요'; } if (fee <= 0) { return '참가비는 0원 이상이어야 합니다'; } else if (fee > 1000000) { return '최대 금액은 1,000,000원입니다'; } else if (fee >= 100000) { return '참가비: ${_currencyFormat.format(fee)}'; } else { return '참가비: ${_currencyFormat.format(fee)}'; } } // 참가비 입력 상태에 따른 색상 반환 Color _getParticipantFeeColor() { if (_participantFeeController.text.isEmpty) { return Colors.grey[600]!; } final fee = double.tryParse(_participantFeeController.text); if (fee == null) { return Colors.red[700]!; } if (fee <= 0) { return Colors.red[700]!; } else if (fee > 1000000) { return Colors.orange[700]!; } else { return Colors.green[700]!; } } // 참가비 입력 상태에 따른 아이콘 반환 IconData _getParticipantFeeIcon() { if (_participantFeeController.text.isEmpty) { return Icons.info_outline; } final fee = double.tryParse(_participantFeeController.text); if (fee == null || fee <= 0) { return Icons.error_outline; } else if (fee > 1000000) { return Icons.warning; } else { return Icons.check_circle; } } // 참가비 입력 상태에 따른 상태 텍스트 반환 String _getParticipantFeeStatusText() { if (_participantFeeController.text.isEmpty) { return '참가비가 설정되지 않았습니다'; } final fee = double.tryParse(_participantFeeController.text); if (fee == null) { return '유효하지 않은 금액입니다'; } if (fee <= 0) { return '참가비는 0원 이상이어야 합니다'; } else if (fee > 1000000) { return '최대 금액을 초과했습니다'; } else { return '참가비가 설정되었습니다'; } } }