import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:flutter/services.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:intl/intl.dart'; import '../../utils/event_excel_parser.dart'; import '../../utils/event_csv_parser.dart'; import '../../services/auth_service.dart'; import '../../services/club_service.dart'; import '../../models/event_model.dart'; import '../../services/event_service.dart'; import '../../widgets/loading_indicator.dart'; import 'event_details_screen.dart'; import 'event_form_screen.dart'; import 'event_calendar_screen.dart'; import 'event_import_wizard.dart'; import '../../widgets/dialog_actions.dart'; import '../../widgets/import_result_dialog.dart'; import '../../theme/badges.dart'; class EventsScreen extends StatefulWidget { const EventsScreen({super.key}); @override State createState() => _EventsScreenState(); } class _EventsScreenState extends State { bool _isInit = false; bool _isLoading = false; String _searchQuery = ''; final TextEditingController _searchController = TextEditingController(); String _filterType = '전체'; // '전체', '예정', '지난' String _filterStatus = '모든 상태'; // '모든 상태', '활성', '대기', '취소', '완료' String _sortBy = '날짜순'; // '날짜순', '이름순' // 이벤트 상태 정규화: DB 원시값(영문) -> 한국어 라벨 String? _normalizeEventStatus(String? status) { if (status == null) return null; final s = status.toLowerCase().trim(); if (s == 'active' || s == 'enabled' || s == '활성') return '활성'; if (s == 'pending' || s == 'draft' || s == 'scheduled' || s == 'waiting' || s == '대기') return '대기'; if (s == 'canceled' || s == 'cancelled' || s == '취소') return '취소'; if (s == 'completed' || s == 'done' || s == 'finished' || s == '완료') return '완료'; return status; // 알 수 없는 값은 원문 유지 } @override void dispose() { _searchController.dispose(); super.dispose(); } /// 테스트 전용: 파일명을 포함한 바이트 데이터를 직접 주입해 업로드 플로우를 실행 @visibleForTesting Future importFromBytesForTest(String fileName, List bytes) async { final ctx = context; final navigator = Navigator.of(ctx); final messenger = ScaffoldMessenger.of(ctx); final eventService = Provider.of(ctx, listen: false); try { // 로딩 다이얼로그 표시 if (!ctx.mounted) return; showDialog( context: ctx, barrierDismissible: false, builder: (_) => const AlertDialog( content: Column( mainAxisSize: MainAxisSize.min, children: [ CircularProgressIndicator(), SizedBox(height: 16), Text('파일 처리 중...'), SizedBox(height: 8), Text('이벤트 데이터를 추출하고 있습니다.'), ], ), ), ); // 확장자에 따라 CSV/엑셀 파서 분기 + 안전 처리 Map parseResult; final lower = fileName.toLowerCase(); try { if (lower.endsWith('.csv')) { parseResult = EventCsvParser.parseCsvBytes(bytes); } else { parseResult = EventExcelParser.parseExcelBytes(bytes); } } catch (e) { navigator.pop(); messenger.showSnackBar( SnackBar( content: Text( '파일 파싱 중 오류가 발생했습니다: ${e.toString()}\n가능하면 CSV 또는 XLSX 형식을 사용해 주세요.', ), duration: Duration(seconds: 5), ), ); return; } final processedRows = parseResult['processedRows'] as int; final events = (parseResult['validEvents'] as List) .cast>(); final invalidRows = parseResult['invalidRows'] as int; final invalidRowIndices = (parseResult['invalidRowIndices'] as List? ?? const []) .map((e) => e.toString()) .toList(); final Map invalidRowReasons = ((parseResult['invalidRowReasons'] as Map?) ?? const {}).map( (key, value) => MapEntry(key.toString(), value?.toString() ?? ''), ); final error = parseResult['error'] as String?; if (error != null) { navigator.pop(); messenger.showSnackBar( SnackBar(content: Text(error), duration: Duration(seconds: 4)), ); return; } // 파싱 성공 → 생성 로딩 다이얼로그로 전환 if (!ctx.mounted) { if (navigator.canPop()) navigator.pop(); return; } navigator.pop(); if (!ctx.mounted) return; showDialog( context: ctx, barrierDismissible: false, builder: (_) => AlertDialog( content: Column( mainAxisSize: MainAxisSize.min, children: [ const CircularProgressIndicator(), const SizedBox(height: 16), Text('이벤트 ${events.length}개 생성 중...'), ], ), ), ); final results = await eventService.createEventsFromFile(events); // 생성 로딩 다이얼로그 닫기 if (navigator.canPop()) navigator.pop(); // 결과 다이얼로그 표시 if (!ctx.mounted) return; showDialog( context: ctx, builder: (dialogContext) => ImportResultDialog( fileName: fileName, processedRows: processedRows, createdCount: results.length, invalidRows: invalidRows, invalidRowIndices: invalidRowIndices, invalidRowReasons: invalidRowReasons, onClose: () { Navigator.of(dialogContext).pop(); _loadEvents(); }, ), ); } catch (e) { if (navigator.canPop()) navigator.pop(); messenger.showSnackBar( SnackBar( content: Text('파일 처리 중 오류가 발생했습니다: $e'), duration: const Duration(seconds: 4), ), ); } } // 테스트 편의를 위한 공개 훅: 오버레이 없이 복제 플로우를 직접 호출 @visibleForTesting Future invokeDuplicateForTest(Event event) async { final eventService = Provider.of(context, listen: false); eventService.setClubId(event.clubId); await eventService.cloneEvent(event.id); } // 검색어 복원 Future _restoreSearchQuery() async { try { final prefs = await SharedPreferences.getInstance(); final saved = prefs.getString('events_search_query') ?? ''; if (saved.isNotEmpty) { _searchController.text = saved; _searchQuery = saved; } } catch (_) { // ignore restore errors } } // 검색어 저장 Future _saveSearchQuery(String value) async { try { final prefs = await SharedPreferences.getInstance(); await prefs.setString('events_search_query', value); } catch (_) { // ignore save errors } } Future _duplicateEvent(Event event) async { final messenger = ScaffoldMessenger.of(context); try { final eventService = Provider.of(context, listen: false); // cloneEvent는 clubId가 필요하므로 보장 차원에서 주입 eventService.setClubId(event.clubId); await eventService.cloneEvent(event.id); if (!mounted) return; messenger.showSnackBar(const SnackBar(content: Text('이벤트가 복제되었습니다'))); // 목록 새로고침 await _loadEvents(); } catch (e) { messenger.showSnackBar(SnackBar(content: Text('이벤트 복제에 실패했습니다: $e'))); } } @override void didChangeDependencies() { super.didChangeDependencies(); if (!_isInit) { _restoreSearchQuery().then((_) => _loadEvents()); _isInit = true; } } Future _loadEvents() async { // 인증 가드: 로그인 상태가 아니면 목록 로딩을 시도하지 않음 final authService = Provider.of(context, listen: false); if (!authService.isAuthenticated) { // 필요 시 간단 안내만 표시하고 리턴 if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('로그인이 필요합니다. 로그인 후 이벤트를 불러옵니다.')), ); } return; } setState(() { _isLoading = true; }); // 비동기 갭 이후 컨텍스트 직접 참조를 피하기 위해 메신저 캡처 final messenger = ScaffoldMessenger.of(context); try { final clubService = Provider.of(context, listen: false); final eventService = Provider.of(context, listen: false); if (clubService.currentClub != null) { eventService.initialize(authService.token!); await eventService.fetchClubEvents(); } } catch (e) { if (!context.mounted) return; final msg = e.toString(); // 401/인증 관련 메시지에 대한 UX 보강 if (msg.contains('401') || msg.contains('인증') || msg.contains('토큰')) { messenger.showSnackBar( const SnackBar( content: Text('세션이 만료되었거나 인증 정보가 유효하지 않습니다. 다시 로그인해 주세요.'), ), ); } else { messenger.showSnackBar( SnackBar(content: Text('이벤트 목록을 불러오는데 실패했습니다: $e')), ); } } finally { if (mounted) { setState(() { _isLoading = false; }); } } } List _getFilteredEvents(List events) { // 검색어 필터링 List filteredEvents = events; if (_searchQuery.isNotEmpty) { final query = _searchQuery.toLowerCase(); filteredEvents = filteredEvents.where((event) { return event.title.toLowerCase().contains(query) || (event.description?.toLowerCase().contains(query) ?? false) || (event.location?.toLowerCase().contains(query) ?? false); }).toList(); } // 날짜 필터링 final now = DateTime.now(); if (_filterType == '예정') { filteredEvents = filteredEvents .where((event) => event.startDate.isAfter(now)) .toList(); } else if (_filterType == '지난') { filteredEvents = filteredEvents .where((event) => event.startDate.isBefore(now)) .toList(); } // 상태 필터링 if (_filterStatus != '모든 상태') { filteredEvents = filteredEvents .where( (event) => _normalizeEventStatus(event.status) == _filterStatus, ) .toList(); } // 정렬 if (_sortBy == '날짜순') { filteredEvents.sort((a, b) => a.startDate.compareTo(b.startDate)); } else if (_sortBy == '이름순') { filteredEvents.sort((a, b) => a.title.compareTo(b.title)); } return filteredEvents; } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('이벤트'), actions: [ IconButton( tooltip: '캘린더 보기', icon: const Icon(Icons.calendar_today), onPressed: _navigateToCalendarView, ), IconButton( tooltip: '파일에서 생성', icon: const Icon(Icons.file_upload), onPressed: _showFileUploadDialog, ), IconButton( tooltip: '이벤트 추가', icon: const Icon(Icons.add), onPressed: _showAddEventDialog, ), ], ), body: _isLoading ? const LoadingIndicator() : Column( children: [ // 검색 바 Padding( padding: const EdgeInsets.all(16.0), child: Column( children: [ TextField( controller: _searchController, decoration: InputDecoration( hintText: '이벤트 검색', prefixIcon: const Icon(Icons.search), border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), ), contentPadding: const EdgeInsets.symmetric( vertical: 0, ), suffixIcon: _searchQuery.isNotEmpty ? IconButton( icon: const Icon(Icons.clear), onPressed: () { _searchController.clear(); setState(() { _searchQuery = ''; }); }, ) : null, ), onChanged: (value) { setState(() { _searchQuery = value; }); _saveSearchQuery(value); }, ), const SizedBox(height: 8), // 날짜 필터 버튼들 SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( children: [ _buildFilterChip('전체'), _buildFilterChip('예정'), _buildFilterChip('지난'), ], ), ), const SizedBox(height: 8), // 상태 필터 버튼들 SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( children: [ _buildStatusFilterChip('모든 상태'), _buildStatusFilterChip('활성'), _buildStatusFilterChip('대기'), _buildStatusFilterChip('취소'), _buildStatusFilterChip('완료'), ], ), ), const SizedBox(height: 8), // 정렬 옵션 Row( children: [ const Text( '정렬: ', style: TextStyle(fontWeight: FontWeight.bold), ), DropdownButton( value: _sortBy, items: const [ DropdownMenuItem( value: '날짜순', child: Text('날짜순'), ), DropdownMenuItem( value: '이름순', child: Text('이름순'), ), ], onChanged: (value) { if (value != null) { setState(() { _sortBy = value; }); } }, ), ], ), ], ), ), // 이벤트 목록 Expanded( child: Consumer( builder: (context, eventService, _) { if (eventService.isLoading) { return const Center(child: CircularProgressIndicator()); } final filteredEvents = _getFilteredEvents( eventService.events, ); if (filteredEvents.isEmpty) { return Center( child: Text( _searchQuery.isEmpty ? '등록된 이벤트가 없습니다' : '검색 결과가 없습니다', ), ); } return RefreshIndicator( onRefresh: _loadEvents, child: ListView.builder( padding: const EdgeInsets.only(bottom: 16), itemCount: filteredEvents.length, itemBuilder: (context, index) { final event = filteredEvents[index]; return _buildEventCard(event); }, ), ); }, ), ), ], ), // FAB 제거: AppBar actions로 이동 ); } Widget _buildFilterChip(String label) { final isSelected = _filterType == label; return Padding( padding: const EdgeInsets.only(right: 8.0), child: FilterChip( label: Text(label), selected: isSelected, onSelected: (selected) { setState(() { if (selected) { _filterType = label; } else { _filterType = '전체'; } }); }, backgroundColor: Colors.grey.shade200, selectedColor: Colors.blue.shade100, ), ); } Widget _buildStatusFilterChip(String label) { final isSelected = _filterStatus == label; return Padding( padding: const EdgeInsets.only(right: 8.0), child: FilterChip( label: Text(label), selected: isSelected, onSelected: (selected) { setState(() { if (selected) { _filterStatus = label; } else { _filterStatus = '모든 상태'; } }); }, backgroundColor: Colors.grey.shade200, selectedColor: Colors.green.shade100, ), ); } Widget _buildEventCard(Event event) { final dateFormat = DateFormat('yyyy년 MM월 dd일 HH:mm'); final now = DateTime.now(); final isPast = event.startDate.isBefore(now); return Card( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), child: ListTile( contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), leading: Container( width: 48, height: 48, decoration: BoxDecoration( color: isPast ? Colors.grey[200] : Colors.blue[100], borderRadius: BorderRadius.circular(8), ), child: Icon( Icons.event, color: isPast ? Colors.grey[600] : Colors.blue, ), ), title: Text( event.title, style: TextStyle( fontWeight: FontWeight.bold, color: isPast ? Colors.grey[600] : Colors.black, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox(height: 4), if (event.status != null) ...[ Wrap( spacing: 4, runSpacing: 4, children: [BadgeStyles.eventStatus(event.status!)], ), const SizedBox(height: 4), ], Text( dateFormat.format(event.startDate), style: TextStyle( color: isPast ? Colors.grey[500] : Colors.grey[700], ), ), if (event.location != null) Text( event.location!, style: TextStyle( color: isPast ? Colors.grey[500] : Colors.grey[600], ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ], ), trailing: PopupMenuButton( key: ValueKey('event_menu_${event.id}'), icon: const Icon(Icons.more_vert), onSelected: (value) { if (value == 'edit') { _showEditEventDialog(event); } else if (value == 'delete') { _showDeleteConfirmationDialog(event); } else if (value == 'duplicate') { _duplicateEvent(event); } else if (value == 'permanent_delete') { _showPermanentDeleteConfirmationDialog(event); } }, itemBuilder: (context) => [ const PopupMenuItem( value: 'edit', child: Row( children: [ Icon(Icons.edit, size: 18), SizedBox(width: 8), Text('수정'), ], ), ), const PopupMenuItem( value: 'duplicate', child: Row( children: [ Icon(Icons.copy, size: 18), SizedBox(width: 8), Text('복제'), ], ), ), const PopupMenuItem( value: 'delete', child: Row( children: [ Icon(Icons.visibility_off, size: 18), SizedBox(width: 8), Text('숨김'), ], ), ), const PopupMenuItem( value: 'permanent_delete', child: Row( children: [ Icon(Icons.delete_forever, size: 18, color: Colors.red), SizedBox(width: 8), Text('완전 삭제', style: TextStyle(color: Colors.red)), ], ), ), ], ), onTap: () => _navigateToEventDetails(event), ), ); } void _showAddEventDialog() { Navigator.of(context) .push(MaterialPageRoute(builder: (context) => const EventFormScreen())) .then((result) { if (result == true) { // 이벤트가 추가되었으면 목록 새로고침 _loadEvents(); } }); } void _showEditEventDialog(Event event) { final titleController = TextEditingController(text: event.title); final descriptionController = TextEditingController( text: event.description ?? '', ); final locationController = TextEditingController( text: event.location ?? '', ); DateTime selectedDate = event.startDate; TimeOfDay selectedTime = TimeOfDay( hour: event.startDate.hour, minute: event.startDate.minute, ); showDialog( context: context, builder: (context) => AlertDialog( title: const Text('이벤트 수정'), content: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, children: [ TextField( controller: titleController, decoration: const InputDecoration( labelText: '제목', border: OutlineInputBorder(), ), ), const SizedBox(height: 16), TextField( controller: descriptionController, maxLines: 3, decoration: const InputDecoration( labelText: '설명', border: OutlineInputBorder(), ), ), const SizedBox(height: 16), TextField( controller: locationController, decoration: const InputDecoration( labelText: '장소', border: OutlineInputBorder(), ), ), const SizedBox(height: 16), ListTile( title: const Text('날짜'), subtitle: Text( DateFormat('yyyy년 MM월 dd일').format(selectedDate), ), trailing: const Icon(Icons.calendar_today), onTap: () async { final pickedDate = await showDatePicker( context: context, initialDate: selectedDate, firstDate: DateTime.now().subtract( const Duration(days: 365), ), lastDate: DateTime.now().add(const Duration(days: 365)), ); if (pickedDate != null && context.mounted) { setState(() { selectedDate = pickedDate; }); } }, ), ListTile( title: const Text('시간'), subtitle: Text(selectedTime.format(context)), trailing: const Icon(Icons.access_time), onTap: () async { final pickedTime = await showTimePicker( context: context, initialTime: selectedTime, ); if (pickedTime != null && context.mounted) { setState(() { selectedTime = pickedTime; }); } }, ), ], ), ), actions: DialogActions.confirm( onCancel: () => Navigator.of(context).pop(), onConfirm: () async { if (titleController.text.isEmpty) { if (!context.mounted) return; ScaffoldMessenger.of( context, ).showSnackBar(const SnackBar(content: Text('제목은 필수 입력 항목입니다'))); return; } try { final eventService = Provider.of( context, listen: false, ); // 날짜와 시간 결합 final eventDateTime = DateTime( selectedDate.year, selectedDate.month, selectedDate.day, selectedTime.hour, selectedTime.minute, ); await eventService.updateEvent(event.id, { 'title': titleController.text.trim(), 'description': descriptionController.text.trim().isEmpty ? null : descriptionController.text.trim(), 'location': locationController.text.trim().isEmpty ? null : locationController.text.trim(), 'startDate': eventDateTime.toIso8601String(), }); if (!context.mounted) return; Navigator.of(context).pop(); ScaffoldMessenger.of( context, ).showSnackBar(const SnackBar(content: Text('이벤트가 업데이트되었습니다'))); } catch (e) { if (!context.mounted) return; ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text('이벤트 업데이트에 실패했습니다: $e'))); } }, confirmText: '저장', ), ), ); } void _navigateToEventDetails(Event event) { Navigator.of(context) .push( MaterialPageRoute( builder: (context) => EventDetailsScreen(event: event), ), ) .then((result) { if (result == true) { // 이벤트가 수정되었으면 목록 새로고침 _loadEvents(); } }); } void _showDeleteConfirmationDialog(Event event) { showDialog( context: context, builder: (context) => AlertDialog( title: const Text('이벤트 숨김'), content: Text('${event.title} 이벤트를 목록에서 숨길까요? (소프트 삭제)'), actions: DialogActions.confirm( onCancel: () => Navigator.of(context).pop(), onConfirm: () async { try { final eventService = Provider.of( context, listen: false, ); await eventService.deleteEvent(event.id); if (!context.mounted) return; Navigator.of(context).pop(); ScaffoldMessenger.of( context, ).showSnackBar(const SnackBar(content: Text('이벤트가 숨김 처리되었습니다'))); } catch (e) { if (!context.mounted) return; ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text('이벤트 숨김에 실패했습니다: $e'))); } }, destructive: false, confirmText: '숨김', ), ), ); } void _showPermanentDeleteConfirmationDialog(Event event) { showDialog( context: context, builder: (context) => AlertDialog( title: const Text('이벤트 완전 삭제'), content: Text( '정말 "${event.title}" 이벤트를 완전 삭제하시겠습니까?\n이 작업은 되돌릴 수 없으며 관련된 팀, 점수, 참가자 데이터가 모두 삭제됩니다.', ), actions: DialogActions.confirm( onCancel: () => Navigator.of(context).pop(), onConfirm: () async { try { final eventService = Provider.of( context, listen: false, ); await eventService.deleteEventPermanently(event.id); if (!context.mounted) return; Navigator.of(context).pop(); ScaffoldMessenger.of( context, ).showSnackBar(const SnackBar(content: Text('이벤트가 완전 삭제되었습니다'))); } catch (e) { if (!context.mounted) return; ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text('이벤트 완전 삭제에 실패했습니다: $e'))); } }, destructive: true, confirmText: '완전 삭제', ), ), ); } // 캘린더 보기 네비게이션 void _navigateToCalendarView() { Navigator.of( context, ).push(MaterialPageRoute(builder: (_) => const EventCalendarScreen())); } // 파일에서 생성 → 가져오기 마법사로 이동 void _showFileUploadDialog() { Navigator.of(context) .push(MaterialPageRoute(builder: (_) => const EventImportWizard())) .then((result) { if (result != null) { _loadEvents(); } }); } // 호환성: 레거시 참조용 별칭 (내부적으로 마법사 호출) void _pickAndUploadFile() { _showFileUploadDialog(); } }