import 'dart:io'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; import '../../services/event_service.dart'; import '../../services/member_service.dart'; class EventImportWizard extends StatefulWidget { const EventImportWizard({super.key}); @override State createState() => _EventImportWizardState(); } class _EventImportWizardState extends State { int _currentStep = 0; // Step1: upload File? _pickedFile; String? _uploadedFilename; bool _uploading = false; // Step2: preview Map? _previewData; // { participants: [], suggestions: {}, ... } List> _participants = []; bool _loadingPreview = false; String? _sheetName; bool _scoresIncludeHandicap = true; // 미리보기: 점수에 핸디 포함 여부 // Step3: event info final _formKey = GlobalKey(); final _titleCtrl = TextEditingController(); final _locationCtrl = TextEditingController(); DateTime? _startDate; DateTime? _endDate; int _gameCount = 3; // Helpers Widget _buildMessageList( dynamic messages, { required String title, required Color color, }) { final List list; if (messages is List) { list = messages.map((e) => e.toString()).toList(); } else if (messages is Map) { list = messages.values.map((e) => e.toString()).toList(); } else if (messages != null) { list = [messages.toString()]; } else { list = const []; } if (list.isEmpty) return const SizedBox.shrink(); return Container( width: double.infinity, padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: color.withOpacity(0.08), border: Border.all(color: color.withOpacity(0.5)), borderRadius: BorderRadius.circular(8), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon(Icons.info_outline, color: color, size: 18), const SizedBox(width: 6), Text( title, style: TextStyle(color: color, fontWeight: FontWeight.bold), ), ], ), const SizedBox(height: 6), ...list.map( (m) => Padding( padding: const EdgeInsets.symmetric(vertical: 2), child: Text('- $m'), ), ), ], ), ); } Future _openMemberPicker(Map p) async { final memberService = Provider.of(context, listen: false); await showModalBottomSheet( context: context, builder: (ctx) { return SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ ListTile( leading: const Icon(Icons.person_off), title: const Text('게스트'), onTap: () { setState(() { p['memberId'] = null; }); Navigator.of(ctx).pop(); }, ), const Divider(height: 1), Expanded( child: ListView.builder( itemCount: memberService.members.length, itemBuilder: (_, i) { final m = memberService.members[i]; return ListTile( leading: const Icon(Icons.person), title: Text(m.name, overflow: TextOverflow.ellipsis), onTap: () { setState(() { p['memberId'] = m.id; p['name'] = m.name.length > 5 ? m.name.substring(0, 5) : m.name; }); Navigator.of(ctx).pop(); }, ); }, ), ), ], ), ); }, ); } void _normalizeScoresForAll() { setState(() { for (final p in _participants) { final list = >[]; final cur = p['scores']; if (cur is List) { for (var i = 0; i < cur.length && i < _gameCount; i++) { final s = cur[i]; if (s is num) { list.add({'gameNumber': i + 1, 'score': s.toInt()}); } else if (s is Map) { list.add({ 'gameNumber': (s['gameNumber'] ?? (i + 1)) is num ? (s['gameNumber'] as num).toInt() : (i + 1), 'score': int.tryParse((s['score'] ?? 0).toString()) ?? 0, }); } } } // pad zeros to reach gameCount for (var i = list.length; i < _gameCount; i++) { list.add({'gameNumber': i + 1, 'score': 0}); } p['scores'] = list; } }); } List _rowWarnings(Map p) { final w = []; final name = (p['name']?.toString() ?? '').trim(); if (name.isEmpty) w.add('이름 누락'); final scores = p['scores']; if (scores is List) { final len = scores.length; if (_gameCount > 0 && len != _gameCount) { w.add('점수 개수($_gameCount 기대) 불일치: $len'); } } return w; } Future _confirmAndDeleteTempIfAny() async { if (_uploadedFilename == null) return true; final confirmed = await showDialog( context: context, builder: (_) => AlertDialog( title: const Text('가져오기 취소'), content: const Text('업로드된 임시 파일을 삭제하고 마법사를 종료할까요?'), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(false), child: const Text('유지'), ), TextButton( onPressed: () => Navigator.of(context).pop(true), child: const Text('삭제 후 종료'), ), ], ), ); if (confirmed == true) { try { final svc = Provider.of(context, listen: false); await svc.deleteUploadedFile(_uploadedFilename!); } catch (_) { // ignore deletion failure } } return confirmed ?? false; } Future _pickAndUpload() async { final eventService = Provider.of(context, listen: false); setState(() => _uploading = true); try { final result = await FilePicker.platform.pickFiles( type: FileType.custom, allowedExtensions: [ 'xlsx', 'xls', 'csv', 'png', 'jpg', 'jpeg', 'heic', 'webp', ], ); if (result == null || result.files.isEmpty) { setState(() => _uploading = false); return; } final path = result.files.first.path; if (path == null) { setState(() => _uploading = false); ScaffoldMessenger.of( context, ).showSnackBar(const SnackBar(content: Text('파일 경로를 확인할 수 없습니다.'))); return; } _pickedFile = File(path); final resp = await eventService.uploadEventFile(_pickedFile!); _uploadedFilename = resp['filename']?.toString(); if (_uploadedFilename == null) { throw Exception('서버 응답에 filename이 없습니다'); } setState(() {}); await _loadPreview(); setState(() => _currentStep = 1); } catch (e) { ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text('업로드 실패: $e'))); } finally { setState(() => _uploading = false); } } Future _loadPreview() async { final eventService = Provider.of(context, listen: false); final memberService = Provider.of(context, listen: false); if (_uploadedFilename == null) return; setState(() => _loadingPreview = true); try { final data = await eventService.parseEventFile( filename: _uploadedFilename!, sheetName: _sheetName, ); _previewData = data; final list = (data['participants'] as List?) ?? []; _participants = list .map((e) => Map.from(e as Map)) .toList(); // ensure members loaded (no heavy await to keep UX; if already loaded, members is non-empty) if (memberService.members.isEmpty) { try { await memberService.fetchClubMembers(); } catch (_) {} } // auto-match by exact trimmed name final Map nameToId = { for (final m in memberService.members) m.name.trim(): m.id, }; for (final p in _participants) { final nm = (p['name']?.toString() ?? '').trim(); if (nm.isNotEmpty && nameToId.containsKey(nm)) { p['memberId'] = nameToId[nm]; } } // event suggestion final event = Map.from((data['event'] as Map?) ?? {}); _titleCtrl.text = event['title']?.toString() ?? _titleCtrl.text; _locationCtrl.text = event['location']?.toString() ?? _locationCtrl.text; _gameCount = int.tryParse(event['gameCount']?.toString() ?? '') ?? _gameCount; final sd = event['startDate']?.toString(); final ed = event['endDate']?.toString(); if (sd != null) _startDate = DateTime.tryParse(sd); if (ed != null) _endDate = DateTime.tryParse(ed); } catch (e) { ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text('미리보기 로드 실패: $e'))); } finally { setState(() => _loadingPreview = false); } } Future _saveAll() async { if (!_formKey.currentState!.validate()) { return; } final eventService = Provider.of(context, listen: false); try { final eventData = { 'title': _titleCtrl.text.trim(), 'location': _locationCtrl.text.trim().isEmpty ? null : _locationCtrl.text.trim(), 'startDate': _startDate ?? DateTime.now(), 'endDate': _endDate, 'gameCount': _gameCount, 'status': 'draft', 'eventType': 'regular', // 서버에 미리보기에서 선택한 핸디 포함 여부 전달 'scoresIncludeHandicap': _scoresIncludeHandicap, }; // participants minimal fields: name, gender, teamNumber, scores, handicap final payloadParticipants = _participants .map( (p) => { 'name': p['name'], if (p['memberId'] != null) 'memberId': p['memberId'], if (p['teamNumber'] != null) 'teamNumber': p['teamNumber'], if (p['handicap'] != null) 'handicap': p['handicap'], if (p['scores'] != null) 'scores': p['scores'], }, ) .toList(); final resp = await eventService.saveEventFileData( eventData: eventData, participants: payloadParticipants, ); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('이벤트가 저장되었습니다.')), ); if (!mounted) return; Navigator.of(context).pop(resp); } catch (e) { if (!mounted) return; ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text('저장 실패: $e'))); } } Future _pickDate({required bool isStart}) async { final now = DateTime.now(); final picked = await showDatePicker( context: context, initialDate: (isStart ? _startDate : _endDate) ?? now, firstDate: DateTime(now.year - 5), lastDate: DateTime(now.year + 5), ); if (picked == null) return; setState(() { if (isStart) { _startDate = picked; } else { _endDate = picked; } }); } @override void dispose() { _titleCtrl.dispose(); _locationCtrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final dateFmt = DateFormat('yyyy-MM-dd'); return Scaffold( appBar: AppBar(title: const Text('이벤트 가져오기')), body: Stepper( currentStep: _currentStep, onStepContinue: () async { if (_currentStep == 0) { await _pickAndUpload(); } else if (_currentStep == 1) { // allow proceed; editing continues in next step setState(() => _currentStep = 2); } else if (_currentStep == 2) { // block if any participant missing name final missing = _participants.any( (p) => (p['name']?.toString() ?? '').trim().isEmpty, ); if (missing) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('이름이 비어있는 참가자가 있습니다. 미리보기에서 수정해 주세요.'), ), ); return; } if (_formKey.currentState!.validate()) { setState(() => _currentStep = 3); } } else if (_currentStep == 3) { await _saveAll(); } }, onStepCancel: () async { if (_currentStep > 0) { setState(() => _currentStep -= 1); } else { final ok = await _confirmAndDeleteTempIfAny(); if (ok) { if (!mounted) return; Navigator.of(context).pop(); } } }, steps: [ Step( title: const Text('파일 업로드'), isActive: _currentStep >= 0, content: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( _pickedFile != null ? '선택됨: ${_pickedFile!.path.split('/').last}' : '파일을 선택하세요.', ), const SizedBox(height: 12), ElevatedButton.icon( onPressed: _uploading ? null : _pickAndUpload, icon: const Icon(Icons.file_upload), label: Text(_uploading ? '업로드 중...' : '파일 선택 및 업로드'), ), if (_uploadedFilename != null) Padding( padding: const EdgeInsets.only(top: 8.0), child: Text('업로드됨: $_uploadedFilename'), ), ], ), ), Step( title: const Text('미리보기/수정'), isActive: _currentStep >= 1, state: _currentStep > 1 ? StepState.complete : StepState.indexed, content: _loadingPreview ? const Center(child: CircularProgressIndicator()) : Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: Text('참가자 ${_participants.length}명'), ), Flexible( child: Wrap( alignment: WrapAlignment.end, crossAxisAlignment: WrapCrossAlignment.center, spacing: 8, runSpacing: 8, children: [ Row( mainAxisSize: MainAxisSize.min, children: [ Checkbox( value: _scoresIncludeHandicap, onChanged: (v) { if (v == null) return; setState(() => _scoresIncludeHandicap = v); }, ), const Text('점수에 핸디 포함'), ], ), if ((_previewData?['sheets'] is List) && ((_previewData?['sheets'] as List).length > 1)) ConstrainedBox( constraints: const BoxConstraints(maxWidth: 180), child: DropdownButton( isExpanded: true, value: _sheetName ?? ((_previewData!['sheets'] as List).first?.toString()), hint: const Text('시트 선택'), items: (_previewData!['sheets'] as List) .map>( (s) => DropdownMenuItem( value: s.toString(), child: Text(s.toString(), overflow: TextOverflow.ellipsis), ), ) .toList(), onChanged: (v) async { setState(() => _sheetName = v); await _loadPreview(); }, ), ), TextButton.icon( onPressed: _participants.isEmpty ? null : _normalizeScoresForAll, icon: const Icon(Icons.tune, size: 16), label: const Text('점수 개수 맞추기'), ), ], ), ), ], ), // Server-side warnings/errors if present if ((_previewData?['warnings'] != null) || (_previewData?['errors'] != null)) Padding( padding: const EdgeInsets.only(bottom: 6.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (_previewData?['warnings'] != null) _buildMessageList( _previewData!['warnings'], title: '서버 경고', color: Colors.orange, ), if (_previewData?['errors'] != null) _buildMessageList( _previewData!['errors'], title: '서버 오류', color: Colors.red, ), ], ), ), Builder( builder: (context) { final totalWarnings = _participants .map(_rowWarnings) .fold(0, (a, b) => a + b.length); if (totalWarnings == 0) return const SizedBox.shrink(); return Padding( padding: const EdgeInsets.only( top: 4.0, bottom: 4.0, ), child: Row( children: [ const Icon( Icons.warning_amber_rounded, color: Colors.orange, size: 18, ), const SizedBox(width: 6), Text( '경고 ${totalWarnings}건: 이름 누락 또는 점수 개수 불일치', ), ], ), ); }, ), const SizedBox(height: 8), ListView.separated( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), itemBuilder: (_, i) { final p = _participants[i]; final warns = _rowWarnings(p); return Column( children: [ Row( children: [ Expanded( flex: 4, child: TextFormField( initialValue: p['name']?.toString() ?? '', decoration: InputDecoration( isDense: true, labelText: '이름', counterText: '', contentPadding: const EdgeInsets.symmetric( vertical: 8, horizontal: 12, ), suffixIcon: Row( mainAxisSize: MainAxisSize.min, children: [ if (p['memberId'] == null) Padding( padding: const EdgeInsets.only( right: 4, ), child: DecoratedBox( decoration: BoxDecoration( color: Colors.orange .withOpacity(0.15), borderRadius: BorderRadius.circular( 4, ), ), child: Padding( padding: const EdgeInsets.symmetric( horizontal: 4, vertical: 2, ), child: Text( '게', style: Theme.of( context, ).textTheme.labelSmall, ), ), ), ), IconButton( icon: const Icon( Icons.person_search, ), tooltip: '회원 선택', onPressed: () => _openMemberPicker(p), ), ], ), ), maxLength: 5, maxLines: 1, textAlignVertical: TextAlignVertical.center, onChanged: (v) { final t = v .replaceAll('(게스트)', '') .trim(); p['name'] = t; p['memberId'] = null; }, textInputAction: TextInputAction.next, ), ), const SizedBox(width: 8), SizedBox( width: 64, child: TextFormField( initialValue: (p['teamNumber']?.toString() ?? ''), decoration: const InputDecoration( isDense: true, labelText: '팀', ), keyboardType: TextInputType.number, onChanged: (v) => p['teamNumber'] = int.tryParse(v), textInputAction: TextInputAction.next, ), ), const SizedBox(width: 8), SizedBox( width: 92, child: TextFormField( initialValue: (p['handicap']?.toString() ?? ''), decoration: const InputDecoration( isDense: true, labelText: '핸디캡', ), keyboardType: TextInputType.number, onChanged: (v) => p['handicap'] = int.tryParse(v) ?? 0, textInputAction: TextInputAction.next, ), ), ], ), const SizedBox(height: 6), Builder( builder: (_) { final scoresMap = {}; final s = p['scores']; if (s is List) { for (final e in s) { if (e is num) { final idx = scoresMap.length + 1; scoresMap[idx] = e.toInt(); } else if (e is Map) { final gn = int.tryParse( (e['gameNumber'] ?? 0).toString(), ) ?? 0; final sc = int.tryParse( (e['score'] ?? 0).toString(), ) ?? 0; if (gn > 0) scoresMap[gn] = sc; } } } final fields = []; for (var i = 1; i <= _gameCount; i++) { final initial = scoresMap[i]?.toString() ?? ''; fields.add( SizedBox( width: 64, child: TextFormField( initialValue: initial, decoration: InputDecoration( isDense: true, labelText: '${i}G', counterText: '', ), maxLength: 3, keyboardType: TextInputType.number, onChanged: (v) { final cur = >[]; for ( var j = 1; j <= _gameCount; j++ ) { if (j == i) { final n = int.tryParse(v) ?? 0; cur.add({ 'gameNumber': j, 'score': n, }); } else { final prev = scoresMap[j] ?? 0; cur.add({ 'gameNumber': j, 'score': prev, }); } } p['scores'] = cur; }, ), ), ); } return Wrap( spacing: 6, runSpacing: 6, children: fields, ); }, ), if (warns.isNotEmpty) ...[ const SizedBox(height: 4), Align( alignment: Alignment.centerLeft, child: Wrap( spacing: 6, children: warns .map( (t) => Chip( label: Text(t), visualDensity: VisualDensity.compact, padding: const EdgeInsets.symmetric( horizontal: 4, ), backgroundColor: Colors.orange.shade100, ), ) .toList(), ), ), ], ], ); }, separatorBuilder: (_, __) => const Divider(height: 12), itemCount: _participants.length, ), ], ), ), Step( title: const Text('이벤트 정보'), isActive: _currentStep >= 2, state: _currentStep > 2 ? StepState.complete : StepState.indexed, content: Form( key: _formKey, child: Column( children: [ TextFormField( controller: _titleCtrl, decoration: const InputDecoration(labelText: '이벤트 제목'), validator: (v) => (v == null || v.trim().isEmpty) ? '제목을 입력하세요' : null, ), const SizedBox(height: 8), TextFormField( controller: _locationCtrl, decoration: const InputDecoration(labelText: '장소 (선택)'), ), const SizedBox(height: 8), Row( children: [ Expanded( child: InkWell( onTap: () => _pickDate(isStart: true), child: InputDecorator( decoration: const InputDecoration(labelText: '시작일'), child: Text( _startDate != null ? dateFmt.format(_startDate!) : '선택', ), ), ), ), const SizedBox(width: 8), Expanded( child: InkWell( onTap: () => _pickDate(isStart: false), child: InputDecorator( decoration: const InputDecoration( labelText: '종료일 (선택)', ), child: Text( _endDate != null ? dateFmt.format(_endDate!) : '선택', ), ), ), ), ], ), const SizedBox(height: 8), Row( children: [ const Text('게임 수'), const SizedBox(width: 12), DropdownButton( value: _gameCount, items: const [1, 2, 3, 4, 5, 6] .map( (e) => DropdownMenuItem(value: e, child: Text('$e')), ) .toList(), onChanged: (v) => setState(() => _gameCount = v ?? _gameCount), ), ], ), ], ), ), ), Step( title: const Text('확인 및 저장'), isActive: _currentStep >= 3, state: _currentStep == 3 ? StepState.editing : StepState.indexed, content: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('이벤트: ${_titleCtrl.text} (${_gameCount}게임)'), if (_startDate != null) Text('시작일: ${dateFmt.format(_startDate!)}'), if (_endDate != null) Text('종료일: ${dateFmt.format(_endDate!)}'), Text('참가자: ${_participants.length}명'), const SizedBox(height: 12), ElevatedButton.icon( onPressed: _saveAll, icon: const Icon(Icons.save), label: const Text('저장'), ), ], ), ), ], ), ); } }