import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:share_plus/share_plus.dart'; import 'package:flutter/services.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:intl/intl.dart'; import 'package:file_picker/file_picker.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 '../../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 = '날짜순'; // '날짜순', '이름순' @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 { setState(() { _isLoading = true; }); // 비동기 갭 이후 컨텍스트 직접 참조를 피하기 위해 메신저 캡처 final messenger = ScaffoldMessenger.of(context); try { final authService = Provider.of(context, listen: false); 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; 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) => 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( body: _isLoading ? const LoadingIndicator() : Column( children: [ // 상단 버튼 영역 Padding( padding: const EdgeInsets.fromLTRB(16.0, 16.0, 16.0, 0), child: Row( mainAxisAlignment: MainAxisAlignment.end, children: [ OutlinedButton.icon( onPressed: _navigateToCalendarView, icon: const Icon(Icons.calendar_today), label: const Text('캘린더 보기'), style: OutlinedButton.styleFrom( foregroundColor: Colors.blue, side: const BorderSide(color: Colors.blue), ), ), const SizedBox(width: 8), OutlinedButton.icon( onPressed: _showFileUploadDialog, icon: const Icon(Icons.file_upload), label: const Text('파일에서 생성'), style: OutlinedButton.styleFrom( foregroundColor: Colors.blue, side: const BorderSide(color: Colors.blue), ), ), ], ), ), // 검색 바 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); }, ), ); }, ), ), ], ), floatingActionButton: FloatingActionButton( onPressed: _showAddEventDialog, backgroundColor: Colors.blue, child: const Icon(Icons.add, color: Colors.white), ), ); } 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); } }, 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.delete, 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: true, confirmText: '삭제', ), ), ); } // 캘린더 보기 화면으로 이동 void _navigateToCalendarView() { Navigator.of(context).push( MaterialPageRoute(builder: (context) => const EventCalendarScreen()), ); } // 파일에서 이벤트 생성 다이얼로그 표시 void _showFileUploadDialog() { showDialog( context: context, builder: (context) => AlertDialog( title: const Text('파일에서 이벤트 생성'), content: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text('엑셀 파일(.xlsx, .xls)에서 이벤트를 일괄 생성할 수 있습니다.'), const SizedBox(height: 16), const Text( '파일 형식 안내:', style: TextStyle(fontWeight: FontWeight.bold), ), const SizedBox(height: 8), const Text( '• 첫 번째 행은 헤더로 사용됩니다.', style: TextStyle(fontSize: 14), ), const Text( '• 필수 필드: title(이벤트 제목), startDate(시작일)', style: TextStyle(fontSize: 14), ), const Text( '• 선택 필드: description(설명), location(장소), endDate(종료일), status(상태)', style: TextStyle(fontSize: 14), ), const Text( '• 날짜 형식: YYYY-MM-DD 또는 YYYY/MM/DD', style: TextStyle(fontSize: 14), ), const SizedBox(height: 16), Row( children: [ Expanded( child: ElevatedButton.icon( onPressed: _pickAndUploadFile, icon: const Icon(Icons.file_upload), label: const Text('파일 선택'), style: ElevatedButton.styleFrom( backgroundColor: Theme.of(context).primaryColor, foregroundColor: Colors.white, ), ), ), ], ), const SizedBox(height: 8), TextButton.icon( onPressed: () async { // 번들된 CSV 템플릿을 읽어 공유 final data = await rootBundle.loadString('assets/templates/event_import_template.csv'); await SharePlus.instance.share( ShareParams( text: data, subject: 'Event Import Template.csv', ), ); }, icon: const Icon(Icons.download), label: const Text('예시 템플릿 다운로드'), ), const SizedBox(height: 4), TextButton.icon( onPressed: () async { const headers = 'title,startDate,endDate,description,location,status,type,maxParticipants'; await Clipboard.setData(const ClipboardData(text: headers)); if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('CSV 헤더가 클립보드에 복사되었습니다.')), ); }, icon: const Icon(Icons.copy_all), label: const Text('CSV 헤더 복사'), ), ], ), ), actions: DialogActions.confirm( onCancel: () => Navigator.of(context).pop(), onConfirm: () => Navigator.of(context).pop(), confirmText: '닫기', ), ), ); } // 파일 선택 및 업로드 Future _pickAndUploadFile() async { // 비동기 전 context 및 파생 객체 캡처 (use_build_context_synchronously 회피) final ctx = context; final navigator = Navigator.of(ctx); final messenger = ScaffoldMessenger.of(ctx); final eventService = Provider.of(ctx, listen: false); try { // 파일 피커를 사용하여 엑셀 파일 선택 final result = await FilePicker.platform.pickFiles( type: FileType.custom, allowedExtensions: ['xlsx', 'xls', 'csv'], allowMultiple: false, ); if (result == null || result.files.isEmpty) { // 사용자가 파일 선택을 취소함 return; } final file = result.files.first; final fileName = file.name; final bytes = file.bytes; if (bytes == null) { messenger.showSnackBar( const SnackBar( content: Text('파일을 읽을 수 없습니다. 다른 파일을 선택해주세요.'), duration: Duration(seconds: 3), ), ); return; } // 로딩 다이얼로그 표시 if (!ctx.mounted) return; showDialog( context: ctx, barrierDismissible: false, builder: (_) => AlertDialog( content: Column( mainAxisSize: MainAxisSize.min, children: [ const CircularProgressIndicator(), const SizedBox(height: 16), Text('파일 "$fileName" 처리 중...'), const SizedBox(height: 8), const 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: const Duration(seconds: 5), ), ); return; } final processedRows = parseResult['processedRows'] as int; final events = parseResult['validEvents'] as List>; 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: const 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; // 상세 결과 다이얼로그 표시 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는 사전 캡처되어 context 불필요) messenger.showSnackBar( SnackBar( content: Text('파일 처리 중 오류가 발생했습니다: $e'), duration: const Duration(seconds: 4), ), ); } } }