이벤트 개발완료

This commit is contained in:
2025-10-23 22:04:03 +09:00
parent ce1aee67d6
commit cd0e139b5e
48 changed files with 7855 additions and 3459 deletions
@@ -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('저장'),
),
],
),
),
],
),
);
}
}
+160 -318
View File
@@ -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();
}
}
+45 -19
View File
@@ -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,
),
],
],
),
),
+344 -253
View File
@@ -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