이벤트 개발완료
This commit is contained in:
@@ -0,0 +1,898 @@
|
||||
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('저장'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user