899 lines
37 KiB
Dart
899 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('저장'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|