이벤트 개발완료
This commit is contained in:
@@ -44,7 +44,8 @@ class _ClubSettingsScreenState extends State<ClubSettingsScreen> {
|
||||
_isLoading = false;
|
||||
_hasEditPermission = true; // 테스트에서는 편집 가능 상태로 둠
|
||||
} else {
|
||||
_loadData();
|
||||
// 첫 프레임 이후에 로드하여 InheritedWidget 접근 안전 보장
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _loadData());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,8 +63,6 @@ class _ClubSettingsScreenState extends State<ClubSettingsScreen> {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
// await 이전에 messenger 캡처 (catch에서 사용)
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
// await 이전에 authService 캡처 (이후 권한 계산에 사용)
|
||||
final authService = Provider.of<AuthService>(context, listen: false);
|
||||
|
||||
@@ -115,7 +114,8 @@ class _ClubSettingsScreenState extends State<ClubSettingsScreen> {
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
messenger.showSnackBar(
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('데이터 로드 중 오류가 발생했습니다: $error')),
|
||||
);
|
||||
} finally {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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('저장'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
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';
|
||||
@@ -17,6 +15,7 @@ import '../../widgets/loading_indicator.dart';
|
||||
import 'event_details_screen.dart';
|
||||
import 'event_form_screen.dart';
|
||||
import 'event_calendar_screen.dart';
|
||||
import 'event_import_wizard.dart';
|
||||
import '../../widgets/dialog_actions.dart';
|
||||
import '../../widgets/import_result_dialog.dart';
|
||||
import '../../theme/badges.dart';
|
||||
@@ -37,6 +36,23 @@ class _EventsScreenState extends State<EventsScreen> {
|
||||
String _filterStatus = '모든 상태'; // '모든 상태', '활성', '대기', '취소', '완료'
|
||||
String _sortBy = '날짜순'; // '날짜순', '이름순'
|
||||
|
||||
// 이벤트 상태 정규화: DB 원시값(영문) -> 한국어 라벨
|
||||
String? _normalizeEventStatus(String? status) {
|
||||
if (status == null) return null;
|
||||
final s = status.toLowerCase().trim();
|
||||
if (s == 'active' || s == 'enabled' || s == '활성') return '활성';
|
||||
if (s == 'pending' ||
|
||||
s == 'draft' ||
|
||||
s == 'scheduled' ||
|
||||
s == 'waiting' ||
|
||||
s == '대기')
|
||||
return '대기';
|
||||
if (s == 'canceled' || s == 'cancelled' || s == '취소') return '취소';
|
||||
if (s == 'completed' || s == 'done' || s == 'finished' || s == '완료')
|
||||
return '완료';
|
||||
return status; // 알 수 없는 값은 원문 유지
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
@@ -83,29 +99,32 @@ class _EventsScreenState extends State<EventsScreen> {
|
||||
navigator.pop();
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('파일 파싱 중 오류가 발생했습니다: ${e.toString()}\n가능하면 CSV 또는 XLSX 형식을 사용해 주세요.'),
|
||||
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<Map<String, dynamic>>();
|
||||
final events = (parseResult['validEvents'] as List)
|
||||
.cast<Map<String, dynamic>>();
|
||||
final invalidRows = parseResult['invalidRows'] as int;
|
||||
final invalidRowIndices = (parseResult['invalidRowIndices'] as List<dynamic>? ?? const [])
|
||||
.map((e) => e.toString())
|
||||
.toList();
|
||||
final Map<String, String> invalidRowReasons = ((parseResult['invalidRowReasons'] as Map?) ?? const {})
|
||||
.map((key, value) => MapEntry(key.toString(), value?.toString() ?? ''));
|
||||
final invalidRowIndices =
|
||||
(parseResult['invalidRowIndices'] as List<dynamic>? ?? const [])
|
||||
.map((e) => e.toString())
|
||||
.toList();
|
||||
final Map<String, String> 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),
|
||||
),
|
||||
SnackBar(content: Text(error), duration: Duration(seconds: 4)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -205,15 +224,11 @@ class _EventsScreenState extends State<EventsScreen> {
|
||||
eventService.setClubId(event.clubId);
|
||||
await eventService.cloneEvent(event.id);
|
||||
if (!mounted) return;
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(content: Text('이벤트가 복제되었습니다')),
|
||||
);
|
||||
messenger.showSnackBar(const SnackBar(content: Text('이벤트가 복제되었습니다')));
|
||||
// 목록 새로고침
|
||||
await _loadEvents();
|
||||
} catch (e) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('이벤트 복제에 실패했습니다: $e')),
|
||||
);
|
||||
messenger.showSnackBar(SnackBar(content: Text('이벤트 복제에 실패했습니다: $e')));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,6 +242,18 @@ class _EventsScreenState extends State<EventsScreen> {
|
||||
}
|
||||
|
||||
Future<void> _loadEvents() async {
|
||||
// 인증 가드: 로그인 상태가 아니면 목록 로딩을 시도하지 않음
|
||||
final authService = Provider.of<AuthService>(context, listen: false);
|
||||
if (!authService.isAuthenticated) {
|
||||
// 필요 시 간단 안내만 표시하고 리턴
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('로그인이 필요합니다. 로그인 후 이벤트를 불러옵니다.')),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
@@ -234,7 +261,6 @@ class _EventsScreenState extends State<EventsScreen> {
|
||||
// 비동기 갭 이후 컨텍스트 직접 참조를 피하기 위해 메신저 캡처
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
try {
|
||||
final authService = Provider.of<AuthService>(context, listen: false);
|
||||
final clubService = Provider.of<ClubService>(context, listen: false);
|
||||
final eventService = Provider.of<EventService>(context, listen: false);
|
||||
|
||||
@@ -244,9 +270,19 @@ class _EventsScreenState extends State<EventsScreen> {
|
||||
}
|
||||
} catch (e) {
|
||||
if (!context.mounted) return;
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('이벤트 목록을 불러오는데 실패했습니다: $e')),
|
||||
);
|
||||
final msg = e.toString();
|
||||
// 401/인증 관련 메시지에 대한 UX 보강
|
||||
if (msg.contains('401') || msg.contains('인증') || msg.contains('토큰')) {
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('세션이 만료되었거나 인증 정보가 유효하지 않습니다. 다시 로그인해 주세요.'),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('이벤트 목록을 불러오는데 실패했습니다: $e')),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
@@ -283,7 +319,9 @@ class _EventsScreenState extends State<EventsScreen> {
|
||||
// 상태 필터링
|
||||
if (_filterStatus != '모든 상태') {
|
||||
filteredEvents = filteredEvents
|
||||
.where((event) => event.status == _filterStatus)
|
||||
.where(
|
||||
(event) => _normalizeEventStatus(event.status) == _filterStatus,
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@@ -300,38 +338,30 @@ class _EventsScreenState extends State<EventsScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('이벤트'),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: '캘린더 보기',
|
||||
icon: const Icon(Icons.calendar_today),
|
||||
onPressed: _navigateToCalendarView,
|
||||
),
|
||||
IconButton(
|
||||
tooltip: '파일에서 생성',
|
||||
icon: const Icon(Icons.file_upload),
|
||||
onPressed: _showFileUploadDialog,
|
||||
),
|
||||
IconButton(
|
||||
tooltip: '이벤트 추가',
|
||||
icon: const Icon(Icons.add),
|
||||
onPressed: _showAddEventDialog,
|
||||
),
|
||||
],
|
||||
),
|
||||
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),
|
||||
@@ -470,11 +500,7 @@ class _EventsScreenState extends State<EventsScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: _showAddEventDialog,
|
||||
backgroundColor: Colors.blue,
|
||||
child: const Icon(Icons.add, color: Colors.white),
|
||||
),
|
||||
// FAB 제거: AppBar actions로 이동
|
||||
);
|
||||
}
|
||||
|
||||
@@ -563,9 +589,7 @@ class _EventsScreenState extends State<EventsScreen> {
|
||||
Wrap(
|
||||
spacing: 4,
|
||||
runSpacing: 4,
|
||||
children: [
|
||||
BadgeStyles.eventStatus(event.status!),
|
||||
],
|
||||
children: [BadgeStyles.eventStatus(event.status!)],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
@@ -596,6 +620,8 @@ class _EventsScreenState extends State<EventsScreen> {
|
||||
_showDeleteConfirmationDialog(event);
|
||||
} else if (value == 'duplicate') {
|
||||
_duplicateEvent(event);
|
||||
} else if (value == 'permanent_delete') {
|
||||
_showPermanentDeleteConfirmationDialog(event);
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
@@ -623,9 +649,19 @@ class _EventsScreenState extends State<EventsScreen> {
|
||||
value: 'delete',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.delete, size: 18, color: Colors.red),
|
||||
Icon(Icons.visibility_off, size: 18),
|
||||
SizedBox(width: 8),
|
||||
Text('삭제', style: TextStyle(color: Colors.red)),
|
||||
Text('숨김'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const PopupMenuItem<String>(
|
||||
value: 'permanent_delete',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.delete_forever, size: 18, color: Colors.red),
|
||||
SizedBox(width: 8),
|
||||
Text('완전 삭제', style: TextStyle(color: Colors.red)),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -741,9 +777,9 @@ class _EventsScreenState extends State<EventsScreen> {
|
||||
onConfirm: () async {
|
||||
if (titleController.text.isEmpty) {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('제목은 필수 입력 항목입니다')),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('제목은 필수 입력 항목입니다')));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -775,14 +811,14 @@ class _EventsScreenState extends State<EventsScreen> {
|
||||
|
||||
if (!context.mounted) return;
|
||||
Navigator.of(context).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('이벤트가 업데이트되었습니다')),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('이벤트가 업데이트되었습니다')));
|
||||
} catch (e) {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('이벤트 업데이트에 실패했습니다: $e')),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('이벤트 업데이트에 실패했습니다: $e')));
|
||||
}
|
||||
},
|
||||
confirmText: '저장',
|
||||
@@ -810,8 +846,8 @@ class _EventsScreenState extends State<EventsScreen> {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('이벤트 삭제'),
|
||||
content: Text('${event.title} 이벤트를 정말 삭제하시겠습니까?'),
|
||||
title: const Text('이벤트 숨김'),
|
||||
content: Text('${event.title} 이벤트를 목록에서 숨길까요? (소프트 삭제)'),
|
||||
actions: DialogActions.confirm(
|
||||
onCancel: () => Navigator.of(context).pop(),
|
||||
onConfirm: () async {
|
||||
@@ -823,273 +859,79 @@ class _EventsScreenState extends State<EventsScreen> {
|
||||
await eventService.deleteEvent(event.id);
|
||||
if (!context.mounted) return;
|
||||
Navigator.of(context).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('이벤트가 삭제되었습니다')),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('이벤트가 숨김 처리되었습니다')));
|
||||
} catch (e) {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('이벤트 삭제에 실패했습니다: $e')),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('이벤트 숨김에 실패했습니다: $e')));
|
||||
}
|
||||
},
|
||||
destructive: true,
|
||||
confirmText: '삭제',
|
||||
destructive: false,
|
||||
confirmText: '숨김',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 캘린더 보기 화면으로 이동
|
||||
void _navigateToCalendarView() {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (context) => const EventCalendarScreen()),
|
||||
);
|
||||
}
|
||||
|
||||
// 파일에서 이벤트 생성 다이얼로그 표시
|
||||
void _showFileUploadDialog() {
|
||||
void _showPermanentDeleteConfirmationDialog(Event event) {
|
||||
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 헤더 복사'),
|
||||
),
|
||||
],
|
||||
),
|
||||
title: const Text('이벤트 완전 삭제'),
|
||||
content: Text(
|
||||
'정말 "${event.title}" 이벤트를 완전 삭제하시겠습니까?\n이 작업은 되돌릴 수 없으며 관련된 팀, 점수, 참가자 데이터가 모두 삭제됩니다.',
|
||||
),
|
||||
actions: DialogActions.confirm(
|
||||
onCancel: () => Navigator.of(context).pop(),
|
||||
onConfirm: () => Navigator.of(context).pop(),
|
||||
confirmText: '닫기',
|
||||
onConfirm: () async {
|
||||
try {
|
||||
final eventService = Provider.of<EventService>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
await eventService.deleteEventPermanently(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: '완전 삭제',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 파일 선택 및 업로드
|
||||
Future<void> _pickAndUploadFile() async {
|
||||
// 비동기 전 context 및 파생 객체 캡처 (use_build_context_synchronously 회피)
|
||||
final ctx = context;
|
||||
final navigator = Navigator.of(ctx);
|
||||
final messenger = ScaffoldMessenger.of(ctx);
|
||||
final eventService = Provider.of<EventService>(ctx, listen: false);
|
||||
try {
|
||||
// 파일 피커를 사용하여 엑셀 파일 선택
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['xlsx', 'xls', 'csv'],
|
||||
allowMultiple: false,
|
||||
);
|
||||
// 캘린더 보기 네비게이션
|
||||
void _navigateToCalendarView() {
|
||||
Navigator.of(
|
||||
context,
|
||||
).push(MaterialPageRoute(builder: (_) => const EventCalendarScreen()));
|
||||
}
|
||||
|
||||
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<String, dynamic> 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<Map<String, dynamic>>;
|
||||
final invalidRows = parseResult['invalidRows'] as int;
|
||||
final invalidRowIndices = (parseResult['invalidRowIndices'] as List<dynamic>? ?? const [])
|
||||
.map((e) => e.toString())
|
||||
.toList();
|
||||
final Map<String, String> 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();
|
||||
// 파일에서 생성 → 가져오기 마법사로 이동
|
||||
void _showFileUploadDialog() {
|
||||
Navigator.of(context)
|
||||
.push(MaterialPageRoute(builder: (_) => const EventImportWizard()))
|
||||
.then((result) {
|
||||
if (result != null) {
|
||||
_loadEvents();
|
||||
},
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
// 로딩 다이얼로그가 열려 있으면 닫기
|
||||
if (navigator.canPop()) navigator.pop();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 사용자 친화적인 오류 메시지 표시 (messenger는 사전 캡처되어 context 불필요)
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('파일 처리 중 오류가 발생했습니다: $e'),
|
||||
duration: const Duration(seconds: 4),
|
||||
),
|
||||
);
|
||||
}
|
||||
// 호환성: 레거시 참조용 별칭 (내부적으로 마법사 호출)
|
||||
void _pickAndUploadFile() {
|
||||
_showFileUploadDialog();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +84,16 @@ class _MembersScreenState extends State<MembersScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('회원'),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: '회원 추가',
|
||||
icon: const Icon(Icons.person_add),
|
||||
onPressed: _showAddMemberDialog,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// 검색 바
|
||||
@@ -154,14 +164,7 @@ class _MembersScreenState extends State<MembersScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () {
|
||||
// 회원 추가 화면으로 이동 (추후 구현)
|
||||
_showAddMemberDialog();
|
||||
},
|
||||
backgroundColor: Colors.blue,
|
||||
child: const Icon(Icons.add, color: Colors.white),
|
||||
),
|
||||
// FAB 제거: AppBar actions로 이동
|
||||
);
|
||||
}
|
||||
|
||||
@@ -223,6 +226,24 @@ class _MembersScreenState extends State<MembersScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
// 평균
|
||||
if (member.average != null)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(left: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.indigo.shade100,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
'AVG ${member.average!.toStringAsFixed(1)}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.indigo.shade800,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (member.email.isNotEmpty || member.phone != null)
|
||||
@@ -249,26 +270,31 @@ class _MembersScreenState extends State<MembersScreen> {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
// 2열: 모든 뱃지
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 4,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
// 회원 유형 배지 (공통 스타일 적용)
|
||||
// 회원 유형 배지
|
||||
BadgeStyles.memberType(memberTypeText),
|
||||
// 가입일 표시
|
||||
if (member.joinDate != null)
|
||||
Text(
|
||||
'가입: ${formatDate(member.joinDate!)}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
// 상태 표시 (공통 배지 적용)
|
||||
// 상태 배지
|
||||
BadgeStyles.memberStatus(getStatusLabel(member.status)),
|
||||
],
|
||||
),
|
||||
// 3열: 가입일
|
||||
if (member.joinDate != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'가입: ${formatDate(member.joinDate!)}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -11,12 +11,14 @@ class ScoreFormScreen extends StatefulWidget {
|
||||
final String eventId;
|
||||
final Score? score; // null이면 새 점수 추가, 아니면 점수 수정
|
||||
final List<Participant> participants;
|
||||
final int gameCount; // 이벤트의 게임 수 (추가 모드에서 사용)
|
||||
|
||||
const ScoreFormScreen({
|
||||
super.key,
|
||||
required this.eventId,
|
||||
this.score,
|
||||
required this.participants,
|
||||
this.gameCount = 1,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -26,15 +28,12 @@ class ScoreFormScreen extends StatefulWidget {
|
||||
class _ScoreFormScreenState extends State<ScoreFormScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
bool _isLoading = false;
|
||||
bool _useFrameInput = false; // 프레임별 입력 사용 여부
|
||||
|
||||
// 폼 필드 컨트롤러
|
||||
final _notesController = TextEditingController();
|
||||
final _totalScoreController = TextEditingController(); // 총점 컨트롤러 추가
|
||||
final List<TextEditingController> _frameControllers = List.generate(
|
||||
10,
|
||||
(_) => TextEditingController(),
|
||||
);
|
||||
List<TextEditingController> _gameScoreControllers = [];
|
||||
List<TextEditingController> _gameHandicapControllers = [];
|
||||
|
||||
// 점수 데이터
|
||||
String? _selectedParticipantId;
|
||||
@@ -49,7 +48,17 @@ class _ScoreFormScreenState extends State<ScoreFormScreen> {
|
||||
// 수정 모드인 경우 기존 데이터 로드
|
||||
if (widget.score != null) {
|
||||
_notesController.text = widget.score!.notes ?? '';
|
||||
_selectedParticipantId = widget.score!.memberId;
|
||||
// 편집 모드: 기존 점수의 participantId를 우선 사용, 없으면 memberId와 매칭되는 참가자 id를 탐색
|
||||
_selectedParticipantId = widget.score!.participantId;
|
||||
if (_selectedParticipantId == null || _selectedParticipantId!.isEmpty) {
|
||||
final match = widget.participants.firstWhere(
|
||||
(p) => p.memberId == widget.score!.memberId,
|
||||
orElse: () => widget.participants.isNotEmpty
|
||||
? widget.participants.first
|
||||
: Participant(id: '', eventId: widget.eventId, memberId: '', name: '알 수 없음'),
|
||||
);
|
||||
_selectedParticipantId = match.id;
|
||||
}
|
||||
_scoreDate = widget.score!.date;
|
||||
_totalScoreController.text = widget.score!.totalScore.toString();
|
||||
_totalScore = widget.score!.totalScore;
|
||||
@@ -58,21 +67,24 @@ class _ScoreFormScreenState extends State<ScoreFormScreen> {
|
||||
if (widget.score!.handicap != null) {
|
||||
_handicapController.text = widget.score!.handicap.toString();
|
||||
}
|
||||
|
||||
// 프레임별 점수가 있는 경우 프레임별 입력 모드로 설정
|
||||
if (widget.score!.frames.isNotEmpty) {
|
||||
setState(() {
|
||||
_useFrameInput = true;
|
||||
});
|
||||
|
||||
// 프레임별 점수 설정
|
||||
for (int i = 0; i < widget.score!.frames.length && i < 10; i++) {
|
||||
_frameControllers[i].text = widget.score!.frames[i].toString();
|
||||
}
|
||||
// 편집 모드에서는 게임별 입력을 사용하지 않음(단일 점수 수정)
|
||||
_gameScoreControllers = const [];
|
||||
_gameHandicapControllers = const [];
|
||||
} else {
|
||||
// 추가 모드: 참가자 유무와 무관하게 gameCount를 기준으로 컨트롤러 생성
|
||||
if (widget.participants.isNotEmpty) {
|
||||
// 새 점수 추가 시 기본 선택: participant.id 사용
|
||||
_selectedParticipantId = widget.participants.first.id;
|
||||
}
|
||||
} else if (widget.participants.isNotEmpty) {
|
||||
// 새 점수 추가 시 첫 번째 참가자 선택
|
||||
_selectedParticipantId = widget.participants.first.memberId;
|
||||
final count = widget.gameCount > 0 ? widget.gameCount : 1;
|
||||
_gameScoreControllers = List.generate(
|
||||
count,
|
||||
(_) => TextEditingController(),
|
||||
);
|
||||
_gameHandicapControllers = List.generate(
|
||||
count,
|
||||
(_) => TextEditingController(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,31 +93,20 @@ class _ScoreFormScreenState extends State<ScoreFormScreen> {
|
||||
_notesController.dispose();
|
||||
_handicapController.dispose();
|
||||
_totalScoreController.dispose();
|
||||
for (var controller in _frameControllers) {
|
||||
for (var controller in _gameScoreControllers) {
|
||||
controller.dispose();
|
||||
}
|
||||
for (var controller in _gameHandicapControllers) {
|
||||
controller.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// 총점 계산 (프레임별 입력 모드에서만 사용)
|
||||
// 총점 계산은 편집 모드에서만 필요(단일 필드)
|
||||
void _calculateTotalScore() {
|
||||
if (_useFrameInput) {
|
||||
int total = 0;
|
||||
for (var controller in _frameControllers) {
|
||||
if (controller.text.isNotEmpty) {
|
||||
total += int.tryParse(controller.text) ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_totalScore = total;
|
||||
_totalScoreController.text = total.toString();
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
_totalScore = int.tryParse(_totalScoreController.text) ?? 0;
|
||||
});
|
||||
}
|
||||
setState(() {
|
||||
_totalScore = int.tryParse(_totalScoreController.text) ?? 0;
|
||||
});
|
||||
}
|
||||
|
||||
// 점수 저장
|
||||
@@ -121,50 +122,51 @@ class _ScoreFormScreenState extends State<ScoreFormScreen> {
|
||||
try {
|
||||
final eventService = Provider.of<EventService>(context, listen: false);
|
||||
|
||||
// 점수 데이터 준비
|
||||
final Map<String, dynamic> scoreData = {
|
||||
// 공통 메타 (add는 participantId/score/gameNumber만 필수)
|
||||
final common = {
|
||||
'eventId': widget.eventId,
|
||||
'memberId': _selectedParticipantId,
|
||||
'date': _scoreDate.toIso8601String(),
|
||||
'handicap': _handicapController.text.isEmpty
|
||||
? null
|
||||
: int.parse(_handicapController.text),
|
||||
'notes': _notesController.text.trim().isEmpty
|
||||
? null
|
||||
: _notesController.text.trim(),
|
||||
// notes/date는 백엔드 add에서 사용하지 않으므로 제외
|
||||
};
|
||||
|
||||
// 프레임별 입력 모드인 경우
|
||||
if (_useFrameInput) {
|
||||
final frames = _frameControllers
|
||||
.map(
|
||||
(controller) =>
|
||||
controller.text.isEmpty ? 0 : int.parse(controller.text),
|
||||
)
|
||||
.toList();
|
||||
scoreData['frames'] = frames;
|
||||
scoreData['totalScore'] = _totalScore;
|
||||
} else {
|
||||
// 총점 입력 모드인 경우
|
||||
scoreData['totalScore'] = int.tryParse(_totalScoreController.text) ?? 0;
|
||||
scoreData['frames'] = []; // 빈 프레임 배열 전송
|
||||
}
|
||||
|
||||
if (widget.score == null) {
|
||||
// 새 점수 추가
|
||||
await eventService.addScore(widget.eventId, scoreData);
|
||||
// 새 점수 추가: 게임 수 만큼 배치 생성 (게임별 핸디캡 포함)
|
||||
for (int i = 0; i < _gameScoreControllers.length; i++) {
|
||||
final scoreText = _gameScoreControllers[i].text.trim();
|
||||
final handicapText = _gameHandicapControllers[i].text.trim();
|
||||
if (scoreText.isEmpty && handicapText.isEmpty) {
|
||||
continue; // 완전 비어있으면 건너뜀
|
||||
}
|
||||
final gameScore = int.tryParse(scoreText) ?? 0;
|
||||
final gameHandicap = int.tryParse(handicapText) ?? 0;
|
||||
final payload = {
|
||||
...common,
|
||||
'participantId': _selectedParticipantId,
|
||||
'gameNumber': i + 1,
|
||||
'score': gameScore,
|
||||
'handicap': gameHandicap,
|
||||
};
|
||||
await eventService.addScore(widget.eventId, payload);
|
||||
}
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('점수가 추가되었습니다')));
|
||||
}
|
||||
} else {
|
||||
// 기존 점수 수정
|
||||
await eventService.updateScore(
|
||||
widget.eventId,
|
||||
widget.score!.id,
|
||||
scoreData,
|
||||
);
|
||||
// 기존 점수 수정: 서버 요구 필드와 날짜 보존
|
||||
final total = int.tryParse(_totalScoreController.text) ?? 0;
|
||||
final payload = {
|
||||
...common,
|
||||
if (_selectedParticipantId != null) 'participantId': _selectedParticipantId,
|
||||
'gameNumber': widget.score!.gameNumber ?? 1,
|
||||
'score': total,
|
||||
'totalScore': total, // 호환 목적
|
||||
'handicap': _handicapController.text.isEmpty
|
||||
? null
|
||||
: int.parse(_handicapController.text),
|
||||
'date': _scoreDate.toIso8601String(),
|
||||
};
|
||||
await eventService.updateScore(widget.eventId, widget.score!.id, payload);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
@@ -217,7 +219,7 @@ class _ScoreFormScreenState extends State<ScoreFormScreen> {
|
||||
isExpanded: true,
|
||||
items: widget.participants.map((participant) {
|
||||
return DropdownMenuItem(
|
||||
value: participant.memberId,
|
||||
value: participant.id,
|
||||
child: Text(
|
||||
participant.name ?? '이름 없음',
|
||||
maxLines: 1,
|
||||
@@ -266,154 +268,158 @@ class _ScoreFormScreenState extends State<ScoreFormScreen> {
|
||||
// 점수 입력 섹션
|
||||
_buildSectionTitle('점수 입력'),
|
||||
|
||||
// 점수 입력 모드 전환 스위치
|
||||
SwitchListTile(
|
||||
title: const Text('프레임별 점수 입력'),
|
||||
subtitle: const Text('각 프레임마다 점수를 개별적으로 입력합니다'),
|
||||
value: _useFrameInput,
|
||||
activeColor: Theme.of(context).primaryColor,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_useFrameInput = value;
|
||||
if (!value) {
|
||||
// 총점 입력 모드로 전환 시 현재 계산된 총점 유지
|
||||
_totalScoreController.text = _totalScore.toString();
|
||||
} else {
|
||||
// 프레임별 입력 모드로 전환 시 총점 계산
|
||||
_calculateTotalScore();
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 입력 모드에 따라 다른 UI 표시
|
||||
_useFrameInput
|
||||
? Column(
|
||||
children: [
|
||||
// 프레임별 점수 입력
|
||||
GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 5,
|
||||
childAspectRatio: 1.5,
|
||||
crossAxisSpacing: 10,
|
||||
mainAxisSpacing: 10,
|
||||
),
|
||||
itemCount: 10,
|
||||
itemBuilder: (context, index) {
|
||||
return _buildFrameInput(index);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 계산된 총점 표시
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.shade50,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment:
|
||||
MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'계산된 총점:',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_totalScore.toString(),
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.blue,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: TextFormField(
|
||||
// 총점 직접 입력
|
||||
controller: _totalScoreController,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '총점 *',
|
||||
hintText: '게임 총점을 입력하세요',
|
||||
prefixIcon: Icon(Icons.score),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '총점을 입력해주세요';
|
||||
}
|
||||
final score = int.tryParse(value);
|
||||
if (score == null) {
|
||||
return '숫자만 입력해주세요';
|
||||
}
|
||||
if (score < 0 || score > 300) {
|
||||
return '0-300 사이의 값을 입력해주세요';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_totalScore = int.tryParse(value) ?? 0;
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 핸디캡 입력
|
||||
TextFormField(
|
||||
controller: _handicapController,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '핸디캡',
|
||||
hintText: '핸디캡 점수를 입력하세요',
|
||||
prefixIcon: Icon(Icons.add_circle_outline),
|
||||
// 추가 모드: 게임 수만큼 (점수 + 핸디캡 + 합계) 행, 수정 모드: 단일 점수/핸디캡
|
||||
if (widget.score == null) ...[
|
||||
// 런타임에서도 리스트 길이 보정 (hot reload/동적 변경 대비)
|
||||
_ensureGameControllersLength(
|
||||
widget.gameCount > 0 ? widget.gameCount : 1,
|
||||
),
|
||||
validator: (value) {
|
||||
if (value != null && value.isNotEmpty) {
|
||||
final handicap = int.tryParse(value);
|
||||
if (handicap == null) {
|
||||
return '숫자만 입력해주세요';
|
||||
}
|
||||
if (handicap < 0 || handicap > 200) {
|
||||
return '0-200 사이의 값을 입력해주세요';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
// 핸디캡 포함 총점 표시
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
Column(
|
||||
children: [
|
||||
const Text(
|
||||
'핸디캡 포함 총점: ',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
Text(
|
||||
'${_totalScore + (int.tryParse(_handicapController.text) ?? 0)}',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.green,
|
||||
for (int i = 0; i < _gameScoreControllers.length; i++)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12.0),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _gameScoreControllers[i],
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: InputDecoration(
|
||||
labelText: '${i + 1}G 점수',
|
||||
hintText: '0-300',
|
||||
prefixIcon: const Icon(Icons.score),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value != null && value.isNotEmpty) {
|
||||
final score = int.tryParse(value);
|
||||
if (score == null) {
|
||||
return '숫자만 입력';
|
||||
}
|
||||
if (score < 0 || score > 300) {
|
||||
return '0-300';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _gameHandicapControllers[i],
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '핸디캡',
|
||||
prefixIcon: Icon(
|
||||
Icons.add_circle_outline,
|
||||
),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value != null && value.isNotEmpty) {
|
||||
final handicap = int.tryParse(value);
|
||||
if (handicap == null) {
|
||||
return '숫자만 입력';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_PerRowSum(
|
||||
scoreText:
|
||||
(i < _gameScoreControllers.length)
|
||||
? _gameScoreControllers[i].text
|
||||
: '',
|
||||
handicapText:
|
||||
(i < _gameHandicapControllers.length)
|
||||
? _gameHandicapControllers[i].text
|
||||
: '',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 전체 합계 표시
|
||||
const SizedBox(height: 8),
|
||||
_TotalsSummary(
|
||||
totalScore: _sumGameScores(),
|
||||
totalWithHandicap: _sumGameScoresWithHandicap(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
] else
|
||||
TextFormField(
|
||||
controller: _totalScoreController,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '총점 *',
|
||||
hintText: '게임 총점을 입력하세요',
|
||||
prefixIcon: Icon(Icons.score),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '총점을 입력해주세요';
|
||||
}
|
||||
final score = int.tryParse(value);
|
||||
if (score == null) {
|
||||
return '숫자만 입력해주세요';
|
||||
}
|
||||
if (score < 0 || score > 300) {
|
||||
return '0-300 사이의 값을 입력해주세요';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
onChanged: (_) => _calculateTotalScore(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 수정 모드에서만 단일 핸디캡과 합계 표시
|
||||
if (widget.score != null) ...[
|
||||
TextFormField(
|
||||
controller: _handicapController,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '핸디캡',
|
||||
hintText: '핸디캡 점수를 입력하세요',
|
||||
prefixIcon: Icon(Icons.add_circle_outline),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value != null && value.isNotEmpty) {
|
||||
final handicap = int.tryParse(value);
|
||||
if (handicap == null) {
|
||||
return '숫자만 입력해주세요';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
const Text(
|
||||
'핸디캡 포함 총점: ',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
Text(
|
||||
'${_totalScore + (int.tryParse(_handicapController.text) ?? 0)}',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.green,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 메모 섹션
|
||||
@@ -451,43 +457,7 @@ class _ScoreFormScreenState extends State<ScoreFormScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
// 프레임 입력 위젯
|
||||
Widget _buildFrameInput(int frameIndex) {
|
||||
return Column(
|
||||
children: [
|
||||
Text(
|
||||
'${frameIndex + 1}',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _frameControllers[frameIndex],
|
||||
keyboardType: TextInputType.number,
|
||||
textAlign: TextAlign.center,
|
||||
decoration: InputDecoration(
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value != null && value.isNotEmpty) {
|
||||
final score = int.tryParse(value);
|
||||
if (score == null) {
|
||||
return '숫자만';
|
||||
}
|
||||
if (score < 0 || score > 300) {
|
||||
return '0-300';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
onChanged: (_) => _calculateTotalScore(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
// 프레임 입력 UI 제거됨
|
||||
|
||||
// 섹션 제목 위젯
|
||||
Widget _buildSectionTitle(String title) {
|
||||
@@ -503,4 +473,125 @@ class _ScoreFormScreenState extends State<ScoreFormScreen> {
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// 컨트롤러 리스트 길이 보정 (gameCount 변화나 hot reload 대비)
|
||||
Widget _ensureGameControllersLength(int desired) {
|
||||
if (desired < 1) desired = 1;
|
||||
// 점수 컨트롤러 보정
|
||||
while (_gameScoreControllers.length < desired) {
|
||||
_gameScoreControllers.add(TextEditingController());
|
||||
}
|
||||
if (_gameScoreControllers.length > desired) {
|
||||
_gameScoreControllers = _gameScoreControllers.sublist(0, desired);
|
||||
}
|
||||
// 핸디캡 컨트롤러 보정
|
||||
while (_gameHandicapControllers.length < desired) {
|
||||
_gameHandicapControllers.add(TextEditingController());
|
||||
}
|
||||
if (_gameHandicapControllers.length > desired) {
|
||||
_gameHandicapControllers = _gameHandicapControllers.sublist(0, desired);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
// 합계 계산: 점수 합
|
||||
int _sumGameScores() {
|
||||
int sum = 0;
|
||||
for (final c in _gameScoreControllers) {
|
||||
final v = int.tryParse(c.text.trim());
|
||||
if (v != null) sum += v;
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
|
||||
// 합계 계산: 점수+핸디캡 합
|
||||
int _sumGameScoresWithHandicap() {
|
||||
int sum = 0;
|
||||
for (int i = 0; i < _gameScoreControllers.length; i++) {
|
||||
final s = int.tryParse(_gameScoreControllers[i].text.trim()) ?? 0;
|
||||
final hText = (i < _gameHandicapControllers.length)
|
||||
? _gameHandicapControllers[i].text.trim()
|
||||
: '';
|
||||
final h = int.tryParse(hText) ?? 0;
|
||||
sum += s + h;
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
}
|
||||
|
||||
// 행 우측 합계 표시 위젯
|
||||
class _PerRowSum extends StatelessWidget {
|
||||
final String scoreText;
|
||||
final String handicapText;
|
||||
const _PerRowSum({required this.scoreText, required this.handicapText});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final score = int.tryParse(scoreText.trim()) ?? 0;
|
||||
final handicap = int.tryParse(handicapText.trim()) ?? 0;
|
||||
final total = score + handicap;
|
||||
return SizedBox(
|
||||
width: 64,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
const Text('합계'),
|
||||
Text(
|
||||
'$total',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.blue,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 전체 합계 표시 위젯
|
||||
class _TotalsSummary extends StatelessWidget {
|
||||
final int totalScore;
|
||||
final int totalWithHandicap;
|
||||
const _TotalsSummary({
|
||||
required this.totalScore,
|
||||
required this.totalWithHandicap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('총점: '),
|
||||
Text(
|
||||
'$totalScore',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('핸디캡 포함 총점: '),
|
||||
Text(
|
||||
'$totalWithHandicap',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.green,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../../models/event_model.dart';
|
||||
import '../../../utils/url_utils.dart';
|
||||
import '../../../utils/enum_mappings.dart';
|
||||
import '../../../theme/badges.dart';
|
||||
|
||||
class BasicInfoTab extends StatelessWidget {
|
||||
final Event event;
|
||||
final int participantsCount;
|
||||
final VoidCallback onShareEvent;
|
||||
final VoidCallback onSetEventReminders;
|
||||
|
||||
const BasicInfoTab({
|
||||
super.key,
|
||||
required this.event,
|
||||
required this.participantsCount,
|
||||
required this.onShareEvent,
|
||||
required this.onSetEventReminders,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 4,
|
||||
children: [
|
||||
_badge(
|
||||
text: getEventTypeLabel(event.type),
|
||||
color: _getTypeColor(getEventTypeLabel(event.type)),
|
||||
icon: _getTypeIcon(getEventTypeLabel(event.type)),
|
||||
),
|
||||
if (event.status != null)
|
||||
BadgeStyles.eventStatus(
|
||||
getEventStatusLabel(event.status),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.share),
|
||||
onPressed: onShareEvent,
|
||||
tooltip: '이벤트 공유',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
if (event.description != null && event.description!.isNotEmpty) ...[
|
||||
const Text('설명', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[100],
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(event.description ?? ''),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
const Text('이벤트 정보', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 8),
|
||||
_infoRow(Icons.calendar_today, '시작: ${DateFormat('yyyy-MM-dd HH:mm').format(event.startDate)}'),
|
||||
if (event.endDate != null)
|
||||
_infoRow(Icons.calendar_today, '종료: ${DateFormat('yyyy-MM-dd HH:mm').format(event.endDate!)}'),
|
||||
if (event.location != null && event.location!.isNotEmpty)
|
||||
_infoRow(Icons.location_on, '장소: ${event.location}'),
|
||||
if (event.maxParticipants != null)
|
||||
_infoRow(Icons.people, '최대 참가자: ${event.maxParticipants}명'),
|
||||
if (event.gameCount != null)
|
||||
_infoRow(Icons.sports, '게임 수: ${event.gameCount}게임'),
|
||||
if (event.participantFee != null)
|
||||
_infoRow(
|
||||
Icons.attach_money,
|
||||
event.participantFee == 0
|
||||
? '참가비: 무료'
|
||||
: '참가비: ${NumberFormat.currency(locale: 'ko_KR', symbol: '₩', decimalDigits: 0).format(event.participantFee)}',
|
||||
),
|
||||
if (event.registrationDeadline != null)
|
||||
_infoRow(Icons.timer, '신청마감: ${DateFormat('yyyy-MM-dd HH:mm').format(event.registrationDeadline!)}'),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
const Text('공개 접근', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 8),
|
||||
if (event.publicHash != null && event.publicHash!.isNotEmpty) ...[
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _infoRow(
|
||||
Icons.link,
|
||||
UrlUtils.getEventPublicUrl(event.publicHash!),
|
||||
isSelectable: true,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.copy),
|
||||
onPressed: () {
|
||||
final publicUrl = UrlUtils.getEventPublicUrl(event.publicHash!);
|
||||
UrlUtils.copyUrlToClipboard(context, publicUrl);
|
||||
},
|
||||
tooltip: 'URL 복사',
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
if (event.accessPassword != null && event.accessPassword!.isNotEmpty)
|
||||
_infoRow(Icons.lock, '접근 비밀번호: ${event.accessPassword}'),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
const Text('참가자 정보', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 8),
|
||||
_infoRow(Icons.people, '현재 참가자: $participantsCount명'),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton.icon(
|
||||
onPressed: onSetEventReminders,
|
||||
icon: const Icon(Icons.notifications_active),
|
||||
label: const Text('알림 설정'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
foregroundColor: Colors.white,
|
||||
backgroundColor: Colors.green,
|
||||
minimumSize: const Size(double.infinity, 48),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _infoRow(IconData icon, String text, {bool isSelectable = false}) {
|
||||
final textWidget = isSelectable
|
||||
? SelectableText(
|
||||
text,
|
||||
maxLines: 3,
|
||||
onTap: () {},
|
||||
)
|
||||
: Text(text);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(icon, size: 20, color: Colors.blueGrey),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: textWidget),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _badge({required String text, required Color color, required IconData icon}) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: color.withValues(alpha: 0.3)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 16, color: color),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
text,
|
||||
style: TextStyle(fontWeight: FontWeight.w600, color: color),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _getTypeColor(String type) {
|
||||
switch (type) {
|
||||
case '정기전':
|
||||
return Colors.indigo;
|
||||
case '연습':
|
||||
return Colors.teal;
|
||||
case '대회':
|
||||
return Colors.deepOrange;
|
||||
default:
|
||||
return Colors.blueGrey;
|
||||
}
|
||||
}
|
||||
|
||||
IconData _getTypeIcon(String type) {
|
||||
switch (type) {
|
||||
case '정기전':
|
||||
return Icons.event;
|
||||
case '연습':
|
||||
return Icons.fitness_center;
|
||||
case '대회':
|
||||
return Icons.emoji_events;
|
||||
default:
|
||||
return Icons.info;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../models/participant_model.dart';
|
||||
import '../../../models/score_model.dart';
|
||||
import '../../../utils/enum_mappings.dart';
|
||||
import '../../../theme/badges.dart';
|
||||
|
||||
class ParticipantsTab extends StatefulWidget {
|
||||
final List<Participant> participants;
|
||||
final List<Score> scores;
|
||||
final void Function(Participant participant) onEditParticipant;
|
||||
|
||||
const ParticipantsTab({
|
||||
super.key,
|
||||
required this.participants,
|
||||
required this.scores,
|
||||
required this.onEditParticipant,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ParticipantsTab> createState() => _ParticipantsTabState();
|
||||
}
|
||||
|
||||
class _ParticipantsTabState extends State<ParticipantsTab> {
|
||||
String? _filterMemberType; // null: 전체
|
||||
String? _filterStatus; // null: 전체
|
||||
bool? _filterPaid; // null: 전체
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String _buildFilterSummary() {
|
||||
final parts = <String>[];
|
||||
if (_filterStatus != null && _filterStatus!.isNotEmpty)
|
||||
parts.add('상태: ${_filterStatus!}');
|
||||
if (_filterMemberType != null && _filterMemberType!.isNotEmpty)
|
||||
parts.add('유형: ${_filterMemberType!}');
|
||||
if (_filterPaid != null) parts.add('결제: ${_filterPaid! ? '완료' : '미결제'}');
|
||||
final q = _searchController.text.trim();
|
||||
if (q.isNotEmpty) parts.add("검색: '$q'");
|
||||
return parts.isEmpty ? '필터 없음' : parts.join(' · ');
|
||||
}
|
||||
|
||||
bool _hasActiveFilters() {
|
||||
return (_filterStatus != null && _filterStatus!.isNotEmpty) ||
|
||||
(_filterMemberType != null && _filterMemberType!.isNotEmpty) ||
|
||||
(_filterPaid != null) ||
|
||||
(_searchController.text.trim().isNotEmpty);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final Set<String> memberTypeOptions = widget.participants
|
||||
.where((p) => (p.memberType ?? '').isNotEmpty)
|
||||
.map((p) => p.memberType!)
|
||||
.toSet();
|
||||
final Set<String> statusOptions = widget.participants
|
||||
.where((p) => (p.status ?? '').isNotEmpty)
|
||||
.map((p) => p.status!)
|
||||
.toSet();
|
||||
|
||||
final List<Participant> filtered = widget.participants.where((p) {
|
||||
final matchesType =
|
||||
_filterMemberType == null ||
|
||||
(_filterMemberType!.isEmpty) ||
|
||||
p.memberType == _filterMemberType;
|
||||
final matchesStatus =
|
||||
_filterStatus == null ||
|
||||
(_filterStatus!.isEmpty) ||
|
||||
p.status == _filterStatus;
|
||||
final matchesPaid = _filterPaid == null || p.isPaid == _filterPaid;
|
||||
final q = _searchController.text.trim().toLowerCase();
|
||||
final matchesQuery =
|
||||
q.isEmpty || (p.name ?? '').toLowerCase().contains(q);
|
||||
return matchesType && matchesStatus && matchesPaid && matchesQuery;
|
||||
}).toList();
|
||||
|
||||
return ListView.builder(
|
||||
itemCount: filtered.length + 1,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == 0) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ExpansionTile(
|
||||
initiallyExpanded: false,
|
||||
leading: Icon(
|
||||
Icons.filter_list,
|
||||
color: _hasActiveFilters()
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: null,
|
||||
),
|
||||
title: Text(
|
||||
'필터/검색',
|
||||
style: TextStyle(
|
||||
color: _hasActiveFilters()
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: null,
|
||||
fontWeight: _hasActiveFilters()
|
||||
? FontWeight.w600
|
||||
: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
_buildFilterSummary(),
|
||||
style: TextStyle(
|
||||
color: _hasActiveFilters()
|
||||
? Colors.black87
|
||||
: Colors.black54,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
childrenPadding: const EdgeInsets.only(
|
||||
bottom: 8,
|
||||
left: 8,
|
||||
right: 8,
|
||||
),
|
||||
children: [
|
||||
TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: '이름 검색',
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: _searchController.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
setState(() => _searchController.clear());
|
||||
},
|
||||
tooltip: '검색 지우기',
|
||||
)
|
||||
: null,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 4,
|
||||
children: [
|
||||
FilterChip(
|
||||
selected: _filterStatus == null,
|
||||
label: const Text('상태: 전체'),
|
||||
onSelected: (_) =>
|
||||
setState(() => _filterStatus = null),
|
||||
),
|
||||
...statusOptions.map(
|
||||
(s) => FilterChip(
|
||||
selected: _filterStatus == s,
|
||||
label: Text('상태: $s'),
|
||||
onSelected: (_) =>
|
||||
setState(() => _filterStatus = s),
|
||||
),
|
||||
),
|
||||
FilterChip(
|
||||
selected: _filterMemberType == null,
|
||||
label: const Text('유형: 전체'),
|
||||
onSelected: (_) =>
|
||||
setState(() => _filterMemberType = null),
|
||||
),
|
||||
...memberTypeOptions.map(
|
||||
(t) => FilterChip(
|
||||
selected: _filterMemberType == t,
|
||||
label: Text('유형: $t'),
|
||||
onSelected: (_) =>
|
||||
setState(() => _filterMemberType = t),
|
||||
),
|
||||
),
|
||||
FilterChip(
|
||||
selected: _filterPaid == null,
|
||||
label: const Text('결제: 전체'),
|
||||
onSelected: (_) => setState(() => _filterPaid = null),
|
||||
),
|
||||
FilterChip(
|
||||
selected: _filterPaid == true,
|
||||
label: const Text('결제: 완료'),
|
||||
onSelected: (_) => setState(() => _filterPaid = true),
|
||||
),
|
||||
FilterChip(
|
||||
selected: _filterPaid == false,
|
||||
label: const Text('결제: 미결제'),
|
||||
onSelected: (_) =>
|
||||
setState(() => _filterPaid = false),
|
||||
),
|
||||
ActionChip(
|
||||
label: const Text('필터 초기화'),
|
||||
avatar: const Icon(Icons.refresh, size: 16),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_filterMemberType = null;
|
||||
_filterStatus = null;
|
||||
_filterPaid = null;
|
||||
_searchController.clear();
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final participant = filtered[index - 1];
|
||||
// handicap: participant.handicap 없으면 가장 최근 score.handicap 사용 (부호 무관)
|
||||
int? handicap = participant.handicap;
|
||||
if (handicap == null) {
|
||||
Score? latest = widget.scores
|
||||
.where((s) => s.memberId == participant.memberId && s.handicap != null)
|
||||
.fold<Score?>(
|
||||
null,
|
||||
(prev, s) => prev == null || s.date.isAfter(prev.date) ? s : prev,
|
||||
);
|
||||
handicap = latest?.handicap;
|
||||
}
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: ListTile(
|
||||
leading: Builder(
|
||||
builder: (_) {
|
||||
final gender = (participant.gender ?? '').toLowerCase();
|
||||
final bool isFemale =
|
||||
gender == 'female' ||
|
||||
gender == 'f' ||
|
||||
gender == '여' ||
|
||||
gender == '여성';
|
||||
final icon = isFemale ? Icons.female : Icons.male;
|
||||
final color = isFemale ? Colors.pink : Colors.blue;
|
||||
return CircleAvatar(
|
||||
backgroundColor: color.withValues(alpha: 0.1),
|
||||
child: Icon(icon, size: 18, color: color),
|
||||
);
|
||||
},
|
||||
),
|
||||
title: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
participant.name ?? '알 수 없음',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
// Guest small badge
|
||||
if ((participant.memberType ?? '').toLowerCase() == 'guest')
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 2),
|
||||
child: BadgeStyles.guestSmall(size: 16),
|
||||
),
|
||||
if (handicap != null && handicap != 0)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.deepPurple.withValues(alpha: 0.10),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: Colors.deepPurple.withValues(alpha: 0.25),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
handicap > 0 ? '+$handicap' : '$handicap',
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.deepPurple,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (participant.average != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 4),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.indigo.withValues(alpha: 0.10),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: Colors.indigo.withValues(alpha: 0.25),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
participant.average!.toStringAsFixed(1),
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.indigo,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (participant.memberType != null &&
|
||||
participant.memberType!.isNotEmpty &&
|
||||
participant.memberType!.toLowerCase() != 'guest')
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: BadgeStyles.memberType(getMemberTypeLabel(participant.memberType)),
|
||||
),
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (participant.status != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 4),
|
||||
child: BadgeStyles.participantStatus(
|
||||
toKoreanParticipantStatus(participant.status),
|
||||
),
|
||||
),
|
||||
BadgeStyles.payment(participant.isPaid),
|
||||
],
|
||||
),
|
||||
),
|
||||
if ((participant.notes != null) &&
|
||||
participant.notes!.trim().isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: const [
|
||||
Icon(Icons.notes, size: 14, color: Colors.black54),
|
||||
SizedBox(width: 4),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
onTap: () => widget.onEditParticipant(participant),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,674 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../../models/participant_model.dart';
|
||||
import '../../../models/score_model.dart';
|
||||
import '../../../models/member_model.dart';
|
||||
import '../../../models/club_model.dart';
|
||||
import '../../../models/tie_breaker_option.dart';
|
||||
import '../../../utils/tie_breaker_util.dart';
|
||||
import '../../../theme/badges.dart';
|
||||
|
||||
class ScoresTab extends StatefulWidget {
|
||||
final String eventId;
|
||||
final List<Participant> participants;
|
||||
final List<Score> scores;
|
||||
final List<Member> members;
|
||||
final List<Member> clubMembers; // for gender icon lookup
|
||||
final Club? club;
|
||||
|
||||
final bool includeHandicap;
|
||||
final ValueChanged<bool> onIncludeHandicapChanged;
|
||||
|
||||
final bool groupByParticipant;
|
||||
final ValueChanged<bool> onGroupByParticipantChanged;
|
||||
|
||||
final Future<void> Function(Participant participant, List<Score> scores) onOpenBatchEdit;
|
||||
final Future<void> Function(Score score) onEditScore;
|
||||
final void Function(Score score) onDeleteScore;
|
||||
|
||||
const ScoresTab({
|
||||
super.key,
|
||||
required this.eventId,
|
||||
required this.participants,
|
||||
required this.scores,
|
||||
required this.members,
|
||||
required this.clubMembers,
|
||||
required this.club,
|
||||
required this.includeHandicap,
|
||||
required this.onIncludeHandicapChanged,
|
||||
required this.groupByParticipant,
|
||||
required this.onGroupByParticipantChanged,
|
||||
required this.onOpenBatchEdit,
|
||||
required this.onEditScore,
|
||||
required this.onDeleteScore,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ScoresTab> createState() => _ScoresTabState();
|
||||
}
|
||||
|
||||
class _ScoresTabState extends State<ScoresTab> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.scores.isEmpty) {
|
||||
return const Center(child: Text('등록된 점수가 없습니다.'));
|
||||
}
|
||||
|
||||
// 동점자 처리 옵션
|
||||
List<TieBreakerOption>? tieBreakerOptions;
|
||||
if (widget.club != null &&
|
||||
widget.club!.tieBreakerOptions != null &&
|
||||
widget.club!.tieBreakerOptions!.isNotEmpty) {
|
||||
tieBreakerOptions = widget.club!.tieBreakerOptions;
|
||||
}
|
||||
|
||||
// 정렬/순위 계산
|
||||
final sortedScores = TieBreakerUtil.sortScoresByOptions(
|
||||
widget.scores,
|
||||
tieBreakerOptions,
|
||||
widget.members,
|
||||
widget.includeHandicap,
|
||||
);
|
||||
|
||||
final ranks = TieBreakerUtil.computeScoreRanks(
|
||||
sortedScores,
|
||||
includeHandicap: widget.includeHandicap,
|
||||
options: tieBreakerOptions,
|
||||
members: widget.members,
|
||||
);
|
||||
|
||||
// 평균/최고/최저
|
||||
final totalScoreSum = sortedScores.fold<int>(
|
||||
0,
|
||||
(sum, s) => sum + (widget.includeHandicap ? s.totalScore + (s.handicap ?? 0) : s.totalScore),
|
||||
);
|
||||
final averageScore = sortedScores.isNotEmpty ? totalScoreSum / sortedScores.length : 0.0;
|
||||
final int? maxScore = sortedScores.isNotEmpty
|
||||
? sortedScores
|
||||
.map((s) => widget.includeHandicap ? s.totalScore + (s.handicap ?? 0) : s.totalScore)
|
||||
.reduce((a, b) => a > b ? a : b)
|
||||
: null;
|
||||
final int? minScore = sortedScores.isNotEmpty
|
||||
? sortedScores
|
||||
.map((s) => widget.includeHandicap ? s.totalScore + (s.handicap ?? 0) : s.totalScore)
|
||||
.reduce((a, b) => a < b ? a : b)
|
||||
: null;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// Summary cards
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _summaryCard(
|
||||
color: Colors.blue.shade50,
|
||||
icon: const Icon(Icons.analytics, color: Colors.blue),
|
||||
title: '평균',
|
||||
value: averageScore.toStringAsFixed(1),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _summaryCard(
|
||||
color: Colors.green.shade50,
|
||||
icon: const Icon(Icons.trending_up, color: Colors.green),
|
||||
title: '최고',
|
||||
value: maxScore?.toString() ?? '-',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _summaryCard(
|
||||
color: Colors.red.shade50,
|
||||
icon: const Icon(Icons.trending_down, color: Colors.red),
|
||||
title: '최저',
|
||||
value: minScore?.toString() ?? '-',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
margin: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.shade50,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.blue.shade200),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.score, color: Colors.blue),
|
||||
const SizedBox(width: 8),
|
||||
Text('평균 점수: ${averageScore.toStringAsFixed(1)}',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (tieBreakerOptions != null && tieBreakerOptions.isNotEmpty)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green.shade50,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.green.shade200),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('동점자 처리 우선순위:', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 4),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: tieBreakerOptions.map((o) => Chip(
|
||||
label: Text(_tieBreakerLabel(o)),
|
||||
backgroundColor: Colors.green.shade100,
|
||||
)).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
ToggleButtons(
|
||||
isSelected: [widget.groupByParticipant, !widget.groupByParticipant],
|
||||
onPressed: (i) => widget.onGroupByParticipantChanged(i == 0),
|
||||
constraints: const BoxConstraints(minHeight: 32, minWidth: 100),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
children: const [
|
||||
Padding(padding: EdgeInsets.symmetric(horizontal: 8), child: Text('참가자별')),
|
||||
Padding(padding: EdgeInsets.symmetric(horizontal: 8), child: Text('점수별')),
|
||||
],
|
||||
),
|
||||
const Spacer(),
|
||||
Row(
|
||||
children: [
|
||||
const Text('핸디캡 포함'),
|
||||
Switch(
|
||||
value: widget.includeHandicap,
|
||||
onChanged: widget.onIncludeHandicapChanged,
|
||||
),
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: widget.groupByParticipant
|
||||
? _buildGroupedList(sortedScores)
|
||||
: ListView.builder(
|
||||
itemCount: sortedScores.length,
|
||||
itemBuilder: (context, index) {
|
||||
final score = sortedScores[index];
|
||||
final rank = ranks[score.id] ?? (index + 1);
|
||||
var participant = widget.participants.firstWhere(
|
||||
(p) => p.id == (score.participantId ?? ''),
|
||||
orElse: () => Participant(
|
||||
id: '',
|
||||
eventId: '',
|
||||
memberId: score.memberId,
|
||||
name: '알 수 없음',
|
||||
),
|
||||
);
|
||||
if (participant.id.isEmpty) {
|
||||
final byMember = widget.participants.where((p) => p.memberId == score.memberId);
|
||||
if (byMember.isNotEmpty) participant = byMember.first;
|
||||
}
|
||||
return _buildScoreListItem(context, score, rank, participant);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _summaryCard({required Color color, required Icon icon, required String title, required String value}) {
|
||||
return Card(
|
||||
color: color,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
icon,
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(title, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12, color: Colors.black54)),
|
||||
Text(value, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _tieBreakerLabel(TieBreakerOption option) {
|
||||
switch (option) {
|
||||
case TieBreakerOption.lowerHandicap:
|
||||
return '핸디캡 낮은 순';
|
||||
case TieBreakerOption.lowerScoreGap:
|
||||
return '점수 격차 작은 순';
|
||||
case TieBreakerOption.olderAge:
|
||||
return '연장자 우선';
|
||||
case TieBreakerOption.none:
|
||||
return '없음';
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildGroupedList(List<Score> sortedScores) {
|
||||
final summaries = widget.participants.map((p) {
|
||||
final pScores = sortedScores.where((s) => ((s.participantId != null && s.participantId!.isNotEmpty && s.participantId == p.id) || ((s.participantId == null || s.participantId!.isEmpty) && s.memberId == p.memberId))).toList();
|
||||
if (pScores.isEmpty) return null;
|
||||
pScores.sort((a, b) => (a.gameNumber ?? 0).compareTo(b.gameNumber ?? 0));
|
||||
final totals = pScores.map((s) => s.totalScore).toList();
|
||||
final totalsH = pScores.map((s) => s.totalScore + (s.handicap ?? 0)).toList();
|
||||
final total = totals.fold<int>(0, (a, b) => a + b);
|
||||
final totalH = totalsH.fold<int>(0, (a, b) => a + b);
|
||||
final avg = totals.isNotEmpty ? (total / totals.length) : 0.0;
|
||||
final avgH = totalsH.isNotEmpty ? (totalH / totalsH.length) : 0.0;
|
||||
final high = totals.isNotEmpty ? totals.reduce((a, b) => a > b ? a : b) : 0;
|
||||
final highH = totalsH.isNotEmpty ? totalsH.reduce((a, b) => a > b ? a : b) : 0;
|
||||
final low = totals.isNotEmpty ? totals.reduce((a, b) => a < b ? a : b) : 0;
|
||||
final lowH = totalsH.isNotEmpty ? totalsH.reduce((a, b) => a < b ? a : b) : 0;
|
||||
final handicapSum = pScores.fold<int>(0, (a, s) => a + (s.handicap ?? 0));
|
||||
return {
|
||||
'p': p,
|
||||
'scores': pScores,
|
||||
'total': total,
|
||||
'totalH': totalH,
|
||||
'avg': avg,
|
||||
'avgH': avgH,
|
||||
'high': high,
|
||||
'highH': highH,
|
||||
'low': low,
|
||||
'lowH': lowH,
|
||||
'handicapSum': handicapSum,
|
||||
};
|
||||
}).whereType<Map<String, dynamic>>().toList();
|
||||
|
||||
final ranked = TieBreakerUtil.rankParticipantSummaries(
|
||||
summaries,
|
||||
includeHandicap: widget.includeHandicap,
|
||||
);
|
||||
|
||||
return ListView(
|
||||
children: ranked.map((data) {
|
||||
final rank = data['rank'] as int;
|
||||
final p = data['p'] as Participant;
|
||||
final pScores = data['scores'] as List<Score>;
|
||||
final total = data['total'] as int;
|
||||
final totalH = data['totalH'] as int;
|
||||
final avg = data['avg'] as double;
|
||||
final avgH = data['avgH'] as double;
|
||||
final high = data['high'] as int;
|
||||
final highH = data['highH'] as int;
|
||||
final low = data['low'] as int;
|
||||
final lowH = data['lowH'] as int;
|
||||
final handicapSum = data['handicapSum'] as int;
|
||||
final displayTotal = widget.includeHandicap ? totalH : total;
|
||||
final displayAvg = widget.includeHandicap ? avgH : avg;
|
||||
final subtitle = widget.includeHandicap
|
||||
? '게임 ${pScores.length} · 최저 $lowH · 최고 $highH'
|
||||
: '게임 ${pScores.length} · 최저 $low · 최고 $high';
|
||||
|
||||
Icon? genderIcon;
|
||||
try {
|
||||
final m = widget.clubMembers.firstWhere((cm) => cm.id == p.memberId);
|
||||
final g = m.gender?.toLowerCase();
|
||||
if (g == 'male' || g == 'm' || g == '남' || g == '남성') {
|
||||
genderIcon = const Icon(Icons.male, size: 16, color: Colors.blue);
|
||||
} else if (g == 'female' || g == 'f' || g == '여' || g == '여성') {
|
||||
genderIcon = const Icon(Icons.female, size: 16, color: Colors.pink);
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
return ExpansionTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: _getRankColor(rank),
|
||||
child: Text('$rank', style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
title: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
children: [
|
||||
Flexible(
|
||||
fit: FlexFit.loose,
|
||||
child: Text(
|
||||
p.name ?? '알 수 없음',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Small guest badge next to name (grouped)
|
||||
if ((p.memberType ?? '').toLowerCase() == 'guest')
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 6),
|
||||
child: BadgeStyles.guestSmall(size: 16),
|
||||
),
|
||||
if (genderIcon != null)
|
||||
Icon(genderIcon.icon, size: 20, color: genderIcon.color),
|
||||
if (handicapSum != 0) ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.indigo.shade100,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Text('+$handicapSum', style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: displayAvg >= 200 ? Colors.red.shade400 : Colors.blue.shade100,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Text(
|
||||
displayAvg.toStringAsFixed(1),
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: displayAvg >= 200 ? Colors.white : Colors.black87,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(subtitle, style: const TextStyle(fontSize: 13), overflow: TextOverflow.ellipsis, maxLines: 1),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 1),
|
||||
decoration: BoxDecoration(color: Colors.blue.shade100, borderRadius: BorderRadius.circular(14)),
|
||||
child: Text('$displayTotal', style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w700, color: Colors.black87)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 0),
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: OutlinedButton.icon(
|
||||
icon: const Icon(Icons.edit, size: 18),
|
||||
label: const Text('전체 수정'),
|
||||
onPressed: () => widget.onOpenBatchEdit(p, pScores),
|
||||
style: OutlinedButton.styleFrom(
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 1),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
...pScores.map((s) {
|
||||
final scoreText = widget.includeHandicap
|
||||
? '${s.totalScore + (s.handicap ?? 0)} (${s.totalScore}+${s.handicap ?? 0})'
|
||||
: '${s.totalScore}';
|
||||
final int displayVal = widget.includeHandicap ? (s.totalScore + (s.handicap ?? 0)) : s.totalScore;
|
||||
final gameNo = s.gameNumber?.toString() ?? '';
|
||||
return Dismissible(
|
||||
key: ValueKey('score_${s.id}_${s.gameNumber ?? ''}'),
|
||||
direction: DismissDirection.endToStart,
|
||||
background: Container(
|
||||
alignment: Alignment.centerRight,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
color: Colors.red.shade400,
|
||||
child: const Icon(Icons.delete, color: Colors.white),
|
||||
),
|
||||
onDismissed: (_) => widget.onDeleteScore(s),
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 50),
|
||||
leading: CircleAvatar(
|
||||
radius: 18,
|
||||
child: Text(gameNo.isEmpty ? '-' : gameNo, style: const TextStyle(fontSize: 15)),
|
||||
),
|
||||
title: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (displayVal >= 200)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 1),
|
||||
decoration: BoxDecoration(color: Colors.red.shade400, borderRadius: BorderRadius.circular(12)),
|
||||
child: Text(
|
||||
scoreText,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w700, color: Colors.white),
|
||||
),
|
||||
)
|
||||
else
|
||||
Text(
|
||||
scoreText,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w700, color: Colors.black87),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(DateFormat('yyyy-MM-dd').format(s.date), style: const TextStyle(color: Colors.black54, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
onTap: () => widget.onEditScore(s),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildScoreListItem(BuildContext context, Score score, int rank, Participant participant) {
|
||||
return Dismissible(
|
||||
key: Key('score_${score.id}'),
|
||||
direction: DismissDirection.endToStart,
|
||||
background: Container(
|
||||
alignment: Alignment.centerRight,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
color: Colors.red,
|
||||
child: const Icon(Icons.delete, color: Colors.white),
|
||||
),
|
||||
confirmDismiss: (direction) async {
|
||||
// confirm handled by parent via onDeleteScore as needed; keep swipe immediate
|
||||
return true;
|
||||
},
|
||||
onDismissed: (_) => widget.onDeleteScore(score),
|
||||
child: Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: _getRankColor(rank),
|
||||
child: Text(
|
||||
rank.toString(),
|
||||
key: Key('rank_${rank}_text'),
|
||||
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
title: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
participant.name ?? '알 수 없음',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
// Small guest badge next to name
|
||||
if ((participant.memberType ?? '').toLowerCase() == 'guest')
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 2),
|
||||
child: BadgeStyles.guestSmall(size: 16),
|
||||
),
|
||||
Builder(
|
||||
builder: (ctx) {
|
||||
Member? m;
|
||||
try {
|
||||
m = widget.clubMembers.firstWhere((cm) => cm.id == (participant.memberId.isNotEmpty ? participant.memberId : score.memberId));
|
||||
} catch (_) {}
|
||||
final gender = m?.gender?.toLowerCase();
|
||||
IconData? icon;
|
||||
Color color = Colors.grey;
|
||||
if (gender == 'male' || gender == 'm' || gender == '남' || gender == '남성') {
|
||||
icon = Icons.male;
|
||||
color = Colors.blue;
|
||||
} else if (gender == 'female' || gender == 'f' || gender == '여' || gender == '여성') {
|
||||
icon = Icons.female;
|
||||
color = Colors.pink;
|
||||
}
|
||||
if (icon == null) return const SizedBox.shrink();
|
||||
return Icon(icon, size: 16, color: color);
|
||||
},
|
||||
),
|
||||
if ((score.handicap ?? 0) != 0) ...[
|
||||
const SizedBox(width: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(color: Colors.indigo.shade100, borderRadius: BorderRadius.circular(12)),
|
||||
child: Text(
|
||||
'+${score.handicap}',
|
||||
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Builder(
|
||||
builder: (ctx) {
|
||||
final displayedTotal = widget.includeHandicap ? (score.totalScore + (score.handicap ?? 0)) : score.totalScore;
|
||||
final bool isHigh = displayedTotal >= 200;
|
||||
final bg = isHigh ? Colors.red.shade400 : Colors.blue.shade100;
|
||||
final fg = isHigh ? Colors.white : Colors.black87;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(12)),
|
||||
child: Text('$displayedTotal', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontWeight: FontWeight.w700, fontSize: 14, color: fg)),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('날짜: ${DateFormat('yyyy-MM-dd').format(score.date)}'),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: score.frames.asMap().entries.map((entry) {
|
||||
return Container(
|
||||
width: 30,
|
||||
height: 30,
|
||||
margin: const EdgeInsets.only(right: 4),
|
||||
decoration: BoxDecoration(color: _getScoreColor(entry.value), borderRadius: BorderRadius.circular(4)),
|
||||
child: Center(
|
||||
child: Text(
|
||||
_getFrameDisplay(entry.key, entry.value, score.frames),
|
||||
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: () => widget.onEditScore(score),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// helpers (duplicated from original for isolation)
|
||||
Color _getRankColor(int rank) {
|
||||
switch (rank) {
|
||||
case 1:
|
||||
return Colors.amber.shade700;
|
||||
case 2:
|
||||
return Colors.blueGrey.shade400;
|
||||
case 3:
|
||||
return Colors.brown.shade400;
|
||||
default:
|
||||
return Colors.blue.shade700;
|
||||
}
|
||||
}
|
||||
|
||||
Color _getScoreColor(int score) {
|
||||
if (score == 10) return Colors.green;
|
||||
if (score == 0) return Colors.red;
|
||||
return Colors.blue;
|
||||
}
|
||||
|
||||
String _getFrameDisplay(int frameIndex, int score, List<int> frames) {
|
||||
if (score == 10) return 'X';
|
||||
if (frameIndex > 0 && frames[frameIndex - 1] + score == 10) return '/';
|
||||
return score.toString();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,234 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../models/score_model.dart';
|
||||
import '../../models/participant_model.dart';
|
||||
import '../../services/event_service.dart';
|
||||
|
||||
class ScoreBatchEditScreen extends StatefulWidget {
|
||||
final String eventId;
|
||||
final Participant participant;
|
||||
final List<Score> scores; // 해당 참가자의 기존 점수들 (게임번호 기준 정렬 권장)
|
||||
final int gameCount; // 이벤트 전체 게임 수
|
||||
|
||||
const ScoreBatchEditScreen({
|
||||
super.key,
|
||||
required this.eventId,
|
||||
required this.participant,
|
||||
required this.scores,
|
||||
required this.gameCount,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ScoreBatchEditScreen> createState() => _ScoreBatchEditScreenState();
|
||||
}
|
||||
|
||||
class _ScoreBatchEditScreenState extends State<ScoreBatchEditScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
bool _isSaving = false;
|
||||
late List<TextEditingController> _scoreCtrls;
|
||||
late List<TextEditingController> _handicapCtrls;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 기존 점수 맵: gameNumber -> Score
|
||||
final byGame = <int, Score>{};
|
||||
for (final s in widget.scores) {
|
||||
final gn = s.gameNumber ?? 0;
|
||||
if (gn > 0) byGame[gn] = s;
|
||||
}
|
||||
|
||||
final count = widget.gameCount > 0 ? widget.gameCount : (byGame.keys.isEmpty ? 1 : byGame.keys.reduce((a,b)=>a>b?a:b));
|
||||
_scoreCtrls = List.generate(count, (i) {
|
||||
final gn = i + 1;
|
||||
final s = byGame[gn];
|
||||
return TextEditingController(text: s != null ? s.totalScore.toString() : '');
|
||||
});
|
||||
_handicapCtrls = List.generate(count, (i) {
|
||||
final gn = i + 1;
|
||||
final s = byGame[gn];
|
||||
return TextEditingController(text: s != null ? (s.handicap ?? 0).toString() : '');
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final c in _scoreCtrls) {
|
||||
c.dispose();
|
||||
}
|
||||
for (final c in _handicapCtrls) {
|
||||
c.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _saveAll() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
setState(() => _isSaving = true);
|
||||
try {
|
||||
final eventService = Provider.of<EventService>(context, listen: false);
|
||||
// 기존 점수 맵 구성
|
||||
final byGame = <int, Score>{};
|
||||
for (final s in widget.scores) {
|
||||
final gn = s.gameNumber ?? 0;
|
||||
if (gn > 0) byGame[gn] = s;
|
||||
}
|
||||
final count = _scoreCtrls.length;
|
||||
for (int i = 0; i < count; i++) {
|
||||
final gameNo = i + 1;
|
||||
final scoreText = _scoreCtrls[i].text.trim();
|
||||
final hText = _handicapCtrls[i].text.trim();
|
||||
final hasAny = scoreText.isNotEmpty || hText.isNotEmpty;
|
||||
if (!hasAny) continue; // 완전 비어있으면 건너뜀
|
||||
|
||||
final newScore = int.tryParse(scoreText) ?? 0;
|
||||
final newHdc = int.tryParse(hText.isEmpty ? '0' : hText) ?? 0;
|
||||
final existing = byGame[gameNo];
|
||||
if (existing != null) {
|
||||
// 업데이트: 날짜 보존
|
||||
await eventService.updateScore(widget.eventId, existing.id, {
|
||||
'gameNumber': gameNo,
|
||||
'score': newScore,
|
||||
'totalScore': newScore, // 호환 목적
|
||||
'handicap': newHdc,
|
||||
'date': existing.date.toIso8601String(),
|
||||
});
|
||||
} else {
|
||||
// 신규 추가
|
||||
await eventService.addScore(widget.eventId, {
|
||||
'eventId': widget.eventId,
|
||||
'participantId': widget.participant.id,
|
||||
'gameNumber': gameNo,
|
||||
'score': newScore,
|
||||
'handicap': newHdc,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (mounted) Navigator.of(context).pop(true);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _isSaving = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('일괄 수정 실패: $e')));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final count = _scoreCtrls.length;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('${widget.participant.name ?? '참가자'} - 점수 일괄 수정'),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: _isSaving ? null : _saveAll,
|
||||
icon: const Icon(Icons.save),
|
||||
tooltip: '저장',
|
||||
)
|
||||
],
|
||||
),
|
||||
body: _isSaving
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: Form(
|
||||
key: _formKey,
|
||||
child: ListView.separated(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: count,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 12),
|
||||
itemBuilder: (context, i) {
|
||||
// 기존 점수 찾기
|
||||
final gn = i + 1;
|
||||
final s = widget.scores.firstWhere(
|
||||
(e) => (e.gameNumber ?? 0) == gn,
|
||||
orElse: () => Score(
|
||||
id: '',
|
||||
memberId: widget.participant.memberId,
|
||||
eventId: widget.eventId,
|
||||
clubId: '',
|
||||
frames: const [],
|
||||
totalScore: 0,
|
||||
handicap: 0,
|
||||
gameNumber: gn,
|
||||
date: DateTime.now(),
|
||||
),
|
||||
);
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 14,
|
||||
child: Text(gn.toString()),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text('기록일: ${DateFormat('yyyy-MM-dd').format(s.date)}'),
|
||||
const Spacer(),
|
||||
Text('합계: ${(_scoreCtrls[i].text.isEmpty ? 0 : int.tryParse(_scoreCtrls[i].text) ?? 0) + (int.tryParse(_handicapCtrls[i].text.isEmpty ? '0' : _handicapCtrls[i].text) ?? 0)}'),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _scoreCtrls[i],
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '점수 (0-300)',
|
||||
prefixIcon: Icon(Icons.score),
|
||||
),
|
||||
validator: (v) {
|
||||
if (v == null || v.isEmpty) return null; // 신규 입력은 비워둘 수 있음
|
||||
final n = int.tryParse(v);
|
||||
if (n == null) return '숫자만';
|
||||
if (n < 0 || n > 300) return '0-300';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _handicapCtrls[i],
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '핸디캡',
|
||||
prefixIcon: Icon(Icons.add_circle_outline),
|
||||
),
|
||||
validator: (v) {
|
||||
if (v == null || v.isEmpty) return null;
|
||||
final n = int.tryParse(v);
|
||||
if (n == null) return '숫자만';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: ElevatedButton.icon(
|
||||
icon: const Icon(Icons.save),
|
||||
label: const Text('모든 게임 저장'),
|
||||
onPressed: _isSaving ? null : _saveAll,
|
||||
style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 14)),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user