이벤트 개발완료

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
@@ -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