1577 lines
66 KiB
Dart
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 '참가비가 설정되었습니다';
|
|
}
|
|
}
|
|
}
|