Files
bowlingManager/mobile/lib/screens/club/event_import_wizard.dart
T
2025-11-10 14:14:08 +09:00

900 lines
37 KiB
Dart

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<EventImportWizard> createState() => _EventImportWizardState();
}
class _EventImportWizardState extends State<EventImportWizard> {
int _currentStep = 0;
// Step1: upload
File? _pickedFile;
String? _uploadedFilename;
bool _uploading = false;
// Step2: preview
Map<String, dynamic>?
_previewData; // { participants: [], suggestions: {}, ... }
List<Map<String, dynamic>> _participants = [];
bool _loadingPreview = false;
String? _sheetName;
bool _scoresIncludeHandicap = true; // 미리보기: 점수에 핸디 포함 여부
// Step3: event info
final _formKey = GlobalKey<FormState>();
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<String> 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<void> _openMemberPicker(Map<String, dynamic> p) async {
final memberService = Provider.of<MemberService>(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 = <Map<String, dynamic>>[];
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<String> _rowWarnings(Map<String, dynamic> p) {
final w = <String>[];
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<bool> _confirmAndDeleteTempIfAny() async {
if (_uploadedFilename == null) return true;
final confirmed = await showDialog<bool>(
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<EventService>(context, listen: false);
await svc.deleteUploadedFile(_uploadedFilename!);
} catch (_) {
// ignore deletion failure
}
}
return confirmed ?? false;
}
Future<void> _pickAndUpload() async {
final eventService = Provider.of<EventService>(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<void> _loadPreview() async {
final eventService = Provider.of<EventService>(context, listen: false);
final memberService = Provider.of<MemberService>(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<String, dynamic>.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<String, String> 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<String, dynamic>.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<void> _saveAll() async {
if (!_formKey.currentState!.validate()) {
return;
}
final eventService = Provider.of<EventService>(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<void> _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<String>(
isExpanded: true,
value: _sheetName ??
((_previewData!['sheets'] as List).first?.toString()),
hint: const Text('시트 선택'),
items: (_previewData!['sheets'] as List)
.map<DropdownMenuItem<String>>(
(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<int>(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 = <int, int>{};
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 = <Widget>[];
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 =
<Map<String, dynamic>>[];
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<int>(
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('저장'),
),
],
),
),
],
),
);
}
}