Files
bowlingManager/mobile/lib/screens/club/event_form_screen.dart
T
2025-10-23 22:04:03 +09:00

1577 lines
66 KiB
Dart

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 '../../utils/enum_mappings.dart';
import '../../models/event_model.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<EventFormScreen> createState() => _EventFormScreenState();
}
class _EventFormScreenState extends State<EventFormScreen> {
final _formKey = GlobalKey<FormState>();
bool _isLoading = false;
bool _obscurePassword = true; // 비밀번호 숨김 상태 관리
bool _testSaveMode = false; // 테스트 훅에서 저장 시 네비/스낵바 우회
// 통화 기호 없이 천단위 구분만 적용
final _currencyFormat = NumberFormat.decimalPattern('ko_KR');
// 폼 필드 컨트롤러
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();
// 공개 URL 표시 전용 컨트롤러(읽기 전용, 빌드마다 새로 만들지 않도록 고정)
final TextEditingController _publicUrlDisplayController =
TextEditingController();
// 이벤트 데이터
String? _type;
String? _status;
DateTime _startDate = DateTime.now();
DateTime? _endDate;
DateTime? _registrationDeadline;
// 이벤트 유형 및 상태 옵션(공용 매핑 사용)
late final List<String> _typeOptions = eventTypeOptions.values.toList();
late final List<String> _statusOptions = eventStatusOptions.values.toList();
// 이벤트 상태별 색상 (유지)
final Map<String, Color> _statusColors = {
'활성': Colors.green,
'대기': Colors.amber,
'취소': Colors.red,
'완료': Colors.blue,
};
final Map<String, IconData> _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 ?? '';
// 공개 URL 표시 초기화
if (_publicHashController.text.trim().isNotEmpty) {
_publicUrlDisplayController.text = UrlUtils.getEventPublicUrl(
_publicHashController.text.trim(),
);
} else {
_publicUrlDisplayController.text = '';
}
_accessPasswordController.text = widget.event!.accessPassword ?? '';
// API 값(type/status)을 한국어 라벨로 변환하여 드롭다운에 설정
_type = widget.event!.type != null
? (eventTypeOptions[widget.event!.type] ?? widget.event!.type)
: null;
_status = widget.event!.status != null
? (eventStatusOptions[widget.event!.status] ?? widget.event!.status)
: null;
_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];
// 공개 해시를 동기로 초기화하여 초기 렌더 타이밍 변동을 줄임
if (_publicHashController.text.isEmpty && !_isHashGenerated) {
_isHashGenerated = true;
final hash = UrlUtils.generatePublicHash();
_publicHashController.text = hash;
_publicUrlDisplayController.text = UrlUtils.getEventPublicUrl(hash);
// 스낵바는 테스트에서 불필요하므로 생략, 일반 환경에서도 최초 자동 생성 시에는 표시하지 않음
}
}
// 참가비 입력 컨트롤러에 리스너 추가
_participantFeeController.addListener(_onParticipantFeeChanged);
}
bool _isHashGenerated = false;
// didChangeDependencies 사용 시 프레임 콜백에 의한 타이밍 변동이 생겨 테스트 플래키를 유발할 수 있어 제거
@override
void dispose() {
_titleController.dispose();
_descriptionController.dispose();
_locationController.dispose();
_maxParticipantsController.dispose();
_gameCountController.dispose();
_participantFeeController.removeListener(_onParticipantFeeChanged);
_participantFeeController.dispose();
_formattedFeeController.dispose();
_publicHashController.dispose();
_accessPasswordController.dispose();
_publicUrlDisplayController.dispose();
super.dispose();
}
// 공개 URL 해시 생성
void _generatePublicHash() {
// 이전 해시 저장
final previousHash = _publicHashController.text;
final isNew = previousHash.isEmpty;
// 새 해시 생성
final hash = UrlUtils.generatePublicHash();
setState(() {
_publicHashController.text = hash;
_publicUrlDisplayController.text = UrlUtils.getEventPublicUrl(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<void> _saveEvent() async {
final isValid = _formKey.currentState!.validate();
if (!isValid && !_testSaveMode) {
// 유효성 검사 실패 시 스낵바로 알림
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<EventService>(context, listen: false);
// 라벨을 API 키로 역매핑하는 헬퍼
String? _labelToKey(Map<String, String> map, String? label) {
if (label == null) return null;
try {
return map.entries.firstWhere((e) => e.value == label).key;
} catch (_) {
return label; // 매칭 실패 시 원문 유지(안전장치)
}
}
// 이벤트 데이터 준비 - null 값 처리 개선
// 참가비 보강: 숨겨진 컨트롤러가 비어있을 경우 포맷 필드에서 숫자만 추출하여 사용
double? _resolveParticipantFee() {
if (_participantFeeController.text.isNotEmpty) {
return double.tryParse(_participantFeeController.text);
}
if (_formattedFeeController.text.isNotEmpty) {
final plain = _formattedFeeController.text.replaceAll(RegExp(r'[^0-9]'), '');
if (plain.isNotEmpty) return double.tryParse(plain);
}
return 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(),
// 한국어 라벨을 API 값으로 변환하여 전송
'type': _labelToKey(eventTypeOptions, _type),
'status': _labelToKey(eventStatusOptions, _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),
// 백엔드가 문자열 금액("0.00")을 사용하므로 전송 시에도 문자열로 정렬
// 일부 라우트 호환을 위해 'entryFee'도 함께 전송
'participantFee': (() {
final fee = _resolveParticipantFee();
if (fee == null) return null; // 빈값은 숨김
return fee.toStringAsFixed(2); // 예: 20000.00, 0.00
})(),
'entryFee': (() {
final fee = _resolveParticipantFee();
if (fee == null) return null;
return fee.toStringAsFixed(2);
})(),
'fee': (() {
final fee = _resolveParticipantFee();
if (fee == null) return null;
return fee.toStringAsFixed(2);
})(),
'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 && !_testSaveMode) {
// 테스트가 아니면 스낵바 표시
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 && !_testSaveMode) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('이벤트가 수정되었습니다'),
action: SnackBarAction(label: '확인', onPressed: () {}),
duration: const Duration(seconds: 3),
),
);
}
}
if (mounted) {
// 성공 후 로딩 해제 (테스트/일반 공통)
setState(() {
_isLoading = false;
});
if (!_testSaveMode) {
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')));
}
}
}
}
// 테스트 편의를 위한 공개 훅: 버튼 탭 없이 저장 플로우 직접 호출
@visibleForTesting
Future<void> invokeSaveForTest() async {
_testSaveMode = true;
try {
await _saveEvent();
} finally {
_testSaveMode = false;
}
}
@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<String>(
key: const Key('event_form_type_dropdown'),
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),
),
),
isExpanded: true,
items: _typeOptions
.map(
(type) => DropdownMenuItem<String>(
value: type,
child: Row(
children: [
const Icon(
Icons.category,
size: 18,
color: Colors.grey,
),
const SizedBox(width: 8),
Flexible(
child: Text(
type,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.black87,
),
),
),
],
),
),
)
.toList(),
onChanged: (value) {
setState(() {
_type = value;
});
},
validator: (value) {
if (value == null || value.isEmpty) {
return '이벤트 유형을 선택해주세요';
}
return null;
},
),
const SizedBox(height: 16),
// 상태
DropdownButtonFormField<String>(
key: const Key('event_form_status_dropdown'),
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),
),
),
isExpanded: true,
items: _statusOptions
.map(
(status) => DropdownMenuItem<String>(
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]?.withValues(
alpha: 0.2,
),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color:
_statusColors[status] ??
Colors.grey,
),
),
child: Text(
status,
style: TextStyle(
color: _statusColors[status],
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
)
.toList(),
onChanged: (value) {
setState(() {
_status = value;
});
},
validator: (value) {
if (value == null || value.isEmpty) {
return '상태를 선택해주세요';
}
return null;
},
),
const SizedBox(height: 16),
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: [
Expanded(
child: Text(
'시작 날짜 및 시간 *',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const 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),
Expanded(
child: Text(
'이벤트 시작 날짜와 시간을 설정해주세요 (필수)',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const 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) {
if (!context.mounted) return;
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) {
if (!context.mounted) return;
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) {
if (!context.mounted) return;
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: '참가비를 입력하세요',
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: IconButton(
icon: const Icon(Icons.refresh),
color: Colors.blue,
onPressed: _generatePublicHash,
),
),
],
),
const SizedBox(height: 8),
if (_publicHashController.text.isNotEmpty)
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Colors.blue.withValues(alpha: 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(
UrlUtils.getEventPublicUrl(
_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(
key: const ValueKey('access_password_field'),
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(
key: const ValueKey('save_event_button'),
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 == 0) {
return '참가비: 0원 (무료)';
} else if (fee > 1000000) {
return '최대 금액은 1,000,000원입니다';
} 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 if (fee == 0) {
return '참가비: 0원 (무료)';
} else {
return '참가비가 설정되었습니다';
}
}
}