1211 lines
61 KiB
Dart
1211 lines
61 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
|
|
import '../../../models/event_model.dart';
|
|
import '../../../models/team_model.dart';
|
|
import '../../../models/participant_model.dart';
|
|
import '../../../models/score_model.dart';
|
|
import '../../../theme/badges.dart';
|
|
import '../../../utils/enum_mappings.dart';
|
|
|
|
class TeamsTab extends StatelessWidget {
|
|
final Event event;
|
|
final bool isLoading;
|
|
final List<Team> teams;
|
|
final List<Participant> participants;
|
|
final List<Score> scores;
|
|
final int teamGenerationMethod;
|
|
final TextEditingController teamSizeController;
|
|
final TextEditingController teamCountController;
|
|
final List<Participant> unassignedParticipants;
|
|
|
|
final ValueChanged<int> onChangeTeamGenerationMethod;
|
|
final VoidCallback onGenerateTeams;
|
|
final VoidCallback onSaveTeams;
|
|
final void Function(Team team, Participant participant) onRemoveFromTeam;
|
|
final void Function(Team team) onShowParticipantSelectionDialog;
|
|
final void Function(Participant participant) onShowTeamSelectionDialog;
|
|
final double Function(Team team) calculateTeamAverage;
|
|
final VoidCallback onShareEvent;
|
|
final void Function(
|
|
Participant participant,
|
|
Team fromTeam,
|
|
Team toTeam,
|
|
int toIndex,
|
|
)?
|
|
onMoveParticipant;
|
|
|
|
const TeamsTab({
|
|
super.key,
|
|
required this.event,
|
|
required this.isLoading,
|
|
required this.teams,
|
|
required this.participants,
|
|
required this.scores,
|
|
required this.teamGenerationMethod,
|
|
required this.teamSizeController,
|
|
required this.teamCountController,
|
|
required this.unassignedParticipants,
|
|
required this.onChangeTeamGenerationMethod,
|
|
required this.onGenerateTeams,
|
|
required this.onSaveTeams,
|
|
required this.onRemoveFromTeam,
|
|
required this.onShowParticipantSelectionDialog,
|
|
required this.onShowTeamSelectionDialog,
|
|
required this.calculateTeamAverage,
|
|
required this.onShareEvent,
|
|
this.onMoveParticipant,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (teams.isEmpty) {
|
|
return Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
const Icon(Icons.groups, size: 64, color: Colors.blue),
|
|
const SizedBox(height: 16),
|
|
const Text(
|
|
'생성된 팀이 없습니다.',
|
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
|
),
|
|
const SizedBox(height: 24),
|
|
Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Text(
|
|
'팀 생성 옵션',
|
|
style: TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Row(
|
|
children: [
|
|
Radio<int>(
|
|
value: 1,
|
|
groupValue: teamGenerationMethod,
|
|
onChanged: (value) {
|
|
if (value != null) {
|
|
onChangeTeamGenerationMethod(value);
|
|
}
|
|
},
|
|
),
|
|
const Text('팀 인원수로 나누기'),
|
|
],
|
|
),
|
|
if (teamGenerationMethod == 1)
|
|
Padding(
|
|
padding: const EdgeInsets.only(left: 32.0),
|
|
child: Row(
|
|
children: [
|
|
SizedBox(
|
|
width: 80,
|
|
child: TextFormField(
|
|
controller: teamSizeController,
|
|
decoration: const InputDecoration(
|
|
labelText: '인원수',
|
|
suffixText: '명',
|
|
),
|
|
keyboardType: TextInputType.number,
|
|
inputFormatters: [
|
|
FilteringTextInputFormatter.digitsOnly,
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
Text('총 ${participants.length}명 참가자'),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Row(
|
|
children: [
|
|
Radio<int>(
|
|
value: 2,
|
|
groupValue: teamGenerationMethod,
|
|
onChanged: (value) {
|
|
if (value != null) {
|
|
onChangeTeamGenerationMethod(value);
|
|
}
|
|
},
|
|
),
|
|
const Text('팀 개수로 나누기'),
|
|
],
|
|
),
|
|
if (teamGenerationMethod == 2)
|
|
Padding(
|
|
padding: const EdgeInsets.only(left: 32.0),
|
|
child: Row(
|
|
children: [
|
|
SizedBox(
|
|
width: 80,
|
|
child: TextFormField(
|
|
controller: teamCountController,
|
|
decoration: const InputDecoration(
|
|
labelText: '팀 수',
|
|
suffixText: '개',
|
|
),
|
|
keyboardType: TextInputType.number,
|
|
inputFormatters: [
|
|
FilteringTextInputFormatter.digitsOnly,
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
Text('총 ${participants.length}명 참가자'),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
ElevatedButton.icon(
|
|
onPressed: isLoading ? null : onGenerateTeams,
|
|
icon: isLoading
|
|
? const SizedBox(
|
|
width: 16,
|
|
height: 16,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
color: Colors.white,
|
|
),
|
|
)
|
|
: const Icon(Icons.group_add),
|
|
label: Text(isLoading ? '생성 중...' : '팀 생성하기'),
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: Colors.blue,
|
|
foregroundColor: Colors.white,
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 24,
|
|
vertical: 12,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
return Column(
|
|
children: [
|
|
if (event.description != null && event.description!.isNotEmpty)
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Text(
|
|
'이벤트 설명',
|
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(event.description!),
|
|
],
|
|
),
|
|
),
|
|
if (unassignedParticipants.isNotEmpty)
|
|
Card(
|
|
margin: const EdgeInsets.all(8),
|
|
color: Colors.amber.shade50,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Padding(
|
|
padding: EdgeInsets.all(8.0),
|
|
child: Row(
|
|
children: [
|
|
Icon(Icons.person_off, color: Colors.amber),
|
|
SizedBox(width: 8),
|
|
Text(
|
|
'미배정 참가자',
|
|
style: TextStyle(fontWeight: FontWeight.bold),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const Divider(height: 1),
|
|
// 2열 그리드 + 검색/정렬/필터
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 8,
|
|
vertical: 6,
|
|
),
|
|
child: Builder(
|
|
builder: (context) {
|
|
final query = ValueNotifier<String>('');
|
|
final sortKey = ValueNotifier<String>(
|
|
'name',
|
|
); // name|hcp|avg
|
|
final genderFilter = ValueNotifier<String>(
|
|
'all',
|
|
); // all|male|female
|
|
|
|
List<Participant> apply(List<Participant> src) {
|
|
var list = List<Participant>.from(src);
|
|
// search
|
|
if (query.value.isNotEmpty) {
|
|
list = list
|
|
.where(
|
|
(p) => (p.name ?? '').toLowerCase().contains(
|
|
query.value,
|
|
),
|
|
)
|
|
.toList();
|
|
}
|
|
// gender filter
|
|
if (genderFilter.value != 'all') {
|
|
list = list.where((p) {
|
|
final g = (p.gender ?? '').toLowerCase();
|
|
final isFemale =
|
|
g == 'female' ||
|
|
g == 'f' ||
|
|
g == '여' ||
|
|
g == '여성';
|
|
return genderFilter.value == 'female'
|
|
? isFemale
|
|
: !isFemale;
|
|
}).toList();
|
|
}
|
|
// sort
|
|
list.sort((a, b) {
|
|
if (sortKey.value == 'hcp') {
|
|
int ah =
|
|
a.handicap ??
|
|
(scores
|
|
.where(
|
|
(s) =>
|
|
s.memberId == a.memberId &&
|
|
s.handicap != null,
|
|
)
|
|
.fold<Score?>(
|
|
null,
|
|
(prev, s) =>
|
|
prev == null ||
|
|
s.date.isAfter(prev.date)
|
|
? s
|
|
: prev,
|
|
)
|
|
?.handicap ??
|
|
0);
|
|
int bh =
|
|
b.handicap ??
|
|
(scores
|
|
.where(
|
|
(s) =>
|
|
s.memberId == b.memberId &&
|
|
s.handicap != null,
|
|
)
|
|
.fold<Score?>(
|
|
null,
|
|
(prev, s) =>
|
|
prev == null ||
|
|
s.date.isAfter(prev.date)
|
|
? s
|
|
: prev,
|
|
)
|
|
?.handicap ??
|
|
0);
|
|
return bh.compareTo(ah);
|
|
} else if (sortKey.value == 'avg') {
|
|
double aa = a.average ?? 0;
|
|
double bb = b.average ?? 0;
|
|
return bb.compareTo(aa);
|
|
} else {
|
|
return (a.name ?? '').compareTo(b.name ?? '');
|
|
}
|
|
});
|
|
return list;
|
|
}
|
|
|
|
Widget chipFor(Participant participant) {
|
|
return Draggable<Map<String, dynamic>>(
|
|
data: {
|
|
'participant': participant,
|
|
'fromTeam': Team(
|
|
id: 'unassigned',
|
|
eventId: event.id,
|
|
name: '미배정',
|
|
memberIds: const [],
|
|
),
|
|
},
|
|
feedback: Material(
|
|
type: MaterialType.transparency,
|
|
child: _UnassignedChip(
|
|
participant: participant,
|
|
scores: scores,
|
|
),
|
|
),
|
|
childWhenDragging: const Opacity(
|
|
opacity: 0.3,
|
|
child: SizedBox.shrink(),
|
|
),
|
|
child: _UnassignedChip(
|
|
participant: participant,
|
|
scores: scores,
|
|
),
|
|
);
|
|
}
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: TextField(
|
|
decoration: const InputDecoration(
|
|
hintText: '이름 검색',
|
|
prefixIcon: Icon(Icons.search),
|
|
isDense: true,
|
|
border: OutlineInputBorder(),
|
|
),
|
|
onChanged: (v) =>
|
|
query.value = v.trim().toLowerCase(),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
DropdownButton<String>(
|
|
value: sortKey.value,
|
|
items: const [
|
|
DropdownMenuItem(
|
|
value: 'name',
|
|
child: Text('이름순'),
|
|
),
|
|
DropdownMenuItem(
|
|
value: 'hcp',
|
|
child: Text('핸디 높은순'),
|
|
),
|
|
DropdownMenuItem(
|
|
value: 'avg',
|
|
child: Text('AVG 높은순'),
|
|
),
|
|
],
|
|
onChanged: (v) {
|
|
if (v != null) sortKey.value = v;
|
|
},
|
|
),
|
|
const SizedBox(width: 8),
|
|
DropdownButton<String>(
|
|
value: genderFilter.value,
|
|
items: const [
|
|
DropdownMenuItem(
|
|
value: 'all',
|
|
child: Text('전체 성별'),
|
|
),
|
|
DropdownMenuItem(
|
|
value: 'male',
|
|
child: Text('남성'),
|
|
),
|
|
DropdownMenuItem(
|
|
value: 'female',
|
|
child: Text('여성'),
|
|
),
|
|
],
|
|
onChanged: (v) {
|
|
if (v != null) genderFilter.value = v;
|
|
},
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
SizedBox(
|
|
height: 200,
|
|
child: AnimatedBuilder(
|
|
animation: Listenable.merge([
|
|
query,
|
|
sortKey,
|
|
genderFilter,
|
|
]),
|
|
builder: (context, _) {
|
|
final list = apply(unassignedParticipants);
|
|
return GridView.builder(
|
|
gridDelegate:
|
|
const SliverGridDelegateWithFixedCrossAxisCount(
|
|
crossAxisCount: 2,
|
|
mainAxisSpacing: 6,
|
|
crossAxisSpacing: 6,
|
|
childAspectRatio: 3.8,
|
|
),
|
|
itemCount: list.length,
|
|
itemBuilder: (context, i) => chipFor(list[i]),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Expanded(
|
|
child: ListView.builder(
|
|
itemCount: teams.length,
|
|
itemBuilder: (context, index) {
|
|
final team = teams[index];
|
|
final teamAverage = calculateTeamAverage(team);
|
|
final memberCount = team.memberIds.length;
|
|
|
|
return DragTarget<Map<String, dynamic>>(
|
|
onWillAcceptWithDetails: (details) =>
|
|
details.data['participant'] is Participant,
|
|
onAcceptWithDetails: (details) {
|
|
final Participant p =
|
|
details.data['participant'] as Participant;
|
|
final Team from = details.data['fromTeam'] as Team;
|
|
if (onMoveParticipant != null) {
|
|
onMoveParticipant!(p, from, team, team.memberIds.length);
|
|
}
|
|
},
|
|
builder: (context, candidateData, rejected) => Card(
|
|
margin: const EdgeInsets.symmetric(
|
|
horizontal: 8,
|
|
vertical: 4,
|
|
),
|
|
child: ExpansionTile(
|
|
leading: CircleAvatar(
|
|
backgroundColor: Colors.blue.shade700,
|
|
child: Text(
|
|
'${index + 1}',
|
|
style: const TextStyle(color: Colors.white),
|
|
),
|
|
),
|
|
title: Row(
|
|
children: [
|
|
Text(
|
|
'팀 ${team.name}',
|
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 8,
|
|
vertical: 2,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: Colors.blue.shade100,
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Text('$memberCount명'),
|
|
),
|
|
],
|
|
),
|
|
subtitle: Wrap(
|
|
spacing: 4,
|
|
crossAxisAlignment: WrapCrossAlignment.center,
|
|
children: [
|
|
const Icon(
|
|
Icons.bar_chart,
|
|
size: 16,
|
|
color: Colors.blue,
|
|
),
|
|
Text('팀 에버리지: ${teamAverage.toStringAsFixed(1)}'),
|
|
],
|
|
),
|
|
children: [
|
|
// 상단 드롭존: 리스트 맨 앞(인덱스 0)에 삽입
|
|
DragTarget<Map<String, dynamic>>(
|
|
onWillAcceptWithDetails: (details) =>
|
|
details.data['participant'] is Participant,
|
|
onAcceptWithDetails: (details) {
|
|
final Participant dp =
|
|
details.data['participant'] as Participant;
|
|
final Team from = details.data['fromTeam'] as Team;
|
|
if (onMoveParticipant != null) {
|
|
onMoveParticipant!(dp, from, team, 0);
|
|
}
|
|
},
|
|
builder: (context, candidateData, rejected) {
|
|
final highlight = candidateData.isNotEmpty;
|
|
return AnimatedContainer(
|
|
duration: const Duration(milliseconds: 150),
|
|
curve: Curves.easeOut,
|
|
height: highlight ? 16 : 8,
|
|
color: highlight
|
|
? Colors.blue.shade100
|
|
: Colors.transparent,
|
|
);
|
|
},
|
|
),
|
|
...team.memberIds.asMap().entries.map((entry) {
|
|
final memberIndex = entry.key;
|
|
final memberId = entry.value;
|
|
// 표시는 간단히 이름만. 상세 평균은 외부 계산을 유지하려면 상위에서 데이터를 내려주거나 콜백을 추가.
|
|
final p = participants.firstWhere(
|
|
(pp) => pp.memberId == memberId,
|
|
orElse: () => Participant(
|
|
id: 'unknown_$memberId',
|
|
eventId: event.id,
|
|
memberId: memberId,
|
|
),
|
|
);
|
|
// subtitle는 배지 UI로 대체됨
|
|
|
|
// 행 자체를 드롭 타깃으로 만들어 해당 위치로 삽입(같은 팀 내 재정렬 및 타팀에서 특정 위치 삽입 지원)
|
|
return DragTarget<Map<String, dynamic>>(
|
|
onWillAcceptWithDetails: (details) =>
|
|
details.data['participant'] is Participant,
|
|
onAcceptWithDetails: (details) {
|
|
final Participant dp =
|
|
details.data['participant'] as Participant;
|
|
final Team from = details.data['fromTeam'] as Team;
|
|
if (onMoveParticipant != null) {
|
|
onMoveParticipant!(dp, from, team, memberIndex);
|
|
}
|
|
},
|
|
builder: (context, candidateData, rejected) {
|
|
final highlight = candidateData.isNotEmpty;
|
|
return AnimatedContainer(
|
|
duration: const Duration(milliseconds: 150),
|
|
curve: Curves.easeOut,
|
|
decoration: highlight
|
|
? BoxDecoration(
|
|
color: Colors.blue.shade50,
|
|
border: Border(
|
|
top: BorderSide(
|
|
color: Colors.blue.shade200,
|
|
width: 2,
|
|
),
|
|
bottom: BorderSide(
|
|
color: Colors.blue.shade200,
|
|
width: 2,
|
|
),
|
|
),
|
|
)
|
|
: const BoxDecoration(),
|
|
child: Dismissible(
|
|
key: ValueKey(
|
|
'team_${team.id}_member_${p.memberId}',
|
|
),
|
|
direction: DismissDirection.endToStart,
|
|
background: const SizedBox.shrink(),
|
|
secondaryBackground: Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 16,
|
|
),
|
|
alignment: Alignment.centerRight,
|
|
color: Colors.red.shade50,
|
|
child: const Icon(
|
|
Icons.delete,
|
|
color: Colors.red,
|
|
),
|
|
),
|
|
onDismissed: (_) => onRemoveFromTeam(team, p),
|
|
child: ListTile(
|
|
leading: Builder(
|
|
builder: (_) {
|
|
final gender = (p.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.withOpacity(0.1),
|
|
child: Icon(
|
|
icon,
|
|
size: 18,
|
|
color: color,
|
|
),
|
|
);
|
|
},
|
|
),
|
|
title: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
p.name ?? '알 수 없음',
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
const SizedBox(width: 6),
|
|
// Guest small badge next to name
|
|
if ((p.memberType ?? '').toLowerCase() == 'guest')
|
|
Padding(
|
|
padding: const EdgeInsets.only(right: 2),
|
|
child: BadgeStyles.guestSmall(size: 16),
|
|
),
|
|
// Compact badges next to name
|
|
Builder(
|
|
builder: (_) {
|
|
int? hcp = p.handicap;
|
|
if (hcp == null) {
|
|
Score? latest = scores
|
|
.where(
|
|
(s) =>
|
|
s.memberId ==
|
|
p.memberId &&
|
|
s.handicap != null,
|
|
)
|
|
.fold<Score?>(
|
|
null,
|
|
(prev, s) =>
|
|
prev == null ||
|
|
s.date.isAfter(
|
|
prev.date,
|
|
)
|
|
? s
|
|
: prev,
|
|
);
|
|
hcp = latest?.handicap;
|
|
}
|
|
if (hcp != null && hcp != 0) {
|
|
final String txt = hcp > 0
|
|
? '+$hcp'
|
|
: '$hcp';
|
|
return Container(
|
|
padding:
|
|
const EdgeInsets.symmetric(
|
|
horizontal: 6,
|
|
vertical: 2,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: Colors.deepPurple
|
|
.withOpacity(0.10),
|
|
borderRadius:
|
|
BorderRadius.circular(12),
|
|
border: Border.all(
|
|
color: Colors.deepPurple
|
|
.withOpacity(0.25),
|
|
),
|
|
),
|
|
child: Text(
|
|
txt,
|
|
style: const TextStyle(
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w600,
|
|
color: Colors.deepPurple,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
return const SizedBox.shrink();
|
|
},
|
|
),
|
|
// member type badge (non-guest types only)
|
|
if ((p.memberType ?? '').isNotEmpty && (p.memberType ?? '').toLowerCase() != 'guest') ...[
|
|
const SizedBox(width: 4),
|
|
BadgeStyles.memberType(getMemberTypeLabel(p.memberType)),
|
|
],
|
|
if (p.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(
|
|
p.average!.toStringAsFixed(1),
|
|
style: const TextStyle(
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w600,
|
|
color: Colors.indigo,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
subtitle: const SizedBox.shrink(),
|
|
trailing: Draggable<Map<String, dynamic>>(
|
|
data: {'participant': p, 'fromTeam': team},
|
|
feedback: Material(
|
|
type: MaterialType.transparency,
|
|
child: ConstrainedBox(
|
|
constraints: const BoxConstraints(
|
|
maxWidth: 320,
|
|
),
|
|
child: Transform.scale(
|
|
scale: 1.05,
|
|
child: Card(
|
|
elevation: 6,
|
|
clipBehavior: Clip.antiAlias,
|
|
child: ListTile(
|
|
leading: Builder(
|
|
builder: (_) {
|
|
final gender =
|
|
(p.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
|
|
.withOpacity(0.1),
|
|
child: Icon(
|
|
icon,
|
|
size: 18,
|
|
color: color,
|
|
),
|
|
);
|
|
},
|
|
),
|
|
title: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
p.name ?? '알 수 없음',
|
|
maxLines: 1,
|
|
overflow:
|
|
TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
const SizedBox(width: 6),
|
|
Builder(
|
|
builder: (_) {
|
|
int? hcp = p.handicap;
|
|
if (hcp == null ||
|
|
hcp <= 0) {
|
|
Score? latest = scores
|
|
.where(
|
|
(s) =>
|
|
s.memberId ==
|
|
p.memberId &&
|
|
s.handicap !=
|
|
null &&
|
|
s.handicap! >
|
|
0,
|
|
)
|
|
.fold<Score?>(
|
|
null,
|
|
(prev, s) =>
|
|
prev ==
|
|
null ||
|
|
s.date.isAfter(
|
|
prev.date,
|
|
)
|
|
? s
|
|
: prev,
|
|
);
|
|
hcp = latest?.handicap;
|
|
}
|
|
if (hcp != null &&
|
|
hcp > 0) {
|
|
return 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(
|
|
'HCP $hcp',
|
|
style:
|
|
const TextStyle(
|
|
fontSize: 11,
|
|
fontWeight:
|
|
FontWeight
|
|
.w600,
|
|
color: Colors
|
|
.deepPurple,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
return const SizedBox.shrink();
|
|
},
|
|
),
|
|
if (p.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(
|
|
p.average!
|
|
.toStringAsFixed(
|
|
1,
|
|
),
|
|
style:
|
|
const TextStyle(
|
|
fontSize: 11,
|
|
fontWeight:
|
|
FontWeight
|
|
.w600,
|
|
color: Colors
|
|
.indigo,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
subtitle: Wrap(
|
|
spacing: 6,
|
|
runSpacing: -6,
|
|
children: [
|
|
Builder(
|
|
builder: (_) {
|
|
int? hcp = p.handicap;
|
|
if (hcp == null ||
|
|
hcp <= 0) {
|
|
Score? latest = scores
|
|
.where(
|
|
(s) =>
|
|
s.memberId ==
|
|
p.memberId &&
|
|
s.handicap !=
|
|
null &&
|
|
s.handicap! >
|
|
0,
|
|
)
|
|
.fold<Score?>(
|
|
null,
|
|
(prev, s) =>
|
|
prev ==
|
|
null ||
|
|
s.date.isAfter(
|
|
prev.date,
|
|
)
|
|
? s
|
|
: prev,
|
|
);
|
|
hcp = latest?.handicap;
|
|
}
|
|
if (hcp != null &&
|
|
hcp > 0) {
|
|
return Container(
|
|
padding:
|
|
const EdgeInsets.symmetric(
|
|
horizontal: 8,
|
|
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(
|
|
'HCP $hcp',
|
|
style:
|
|
const TextStyle(
|
|
fontSize: 12,
|
|
fontWeight:
|
|
FontWeight
|
|
.w600,
|
|
color: Colors
|
|
.deepPurple,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
return const SizedBox.shrink();
|
|
},
|
|
),
|
|
if (p.average != null)
|
|
Container(
|
|
padding:
|
|
const EdgeInsets.symmetric(
|
|
horizontal: 8,
|
|
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(
|
|
p.average!
|
|
.toStringAsFixed(1),
|
|
style: const TextStyle(
|
|
fontSize: 12,
|
|
fontWeight:
|
|
FontWeight.w600,
|
|
color: Colors.indigo,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
childWhenDragging: const Opacity(
|
|
opacity: 0.3,
|
|
child: Icon(
|
|
Icons.drag_indicator,
|
|
color: Colors.blueGrey,
|
|
),
|
|
),
|
|
child: const Icon(
|
|
Icons.drag_indicator,
|
|
color: Colors.blueGrey,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}),
|
|
// 하단 드롭존: 리스트 맨 끝(인덱스 length)에 삽입
|
|
DragTarget<Map<String, dynamic>>(
|
|
onWillAcceptWithDetails: (details) =>
|
|
details.data['participant'] is Participant,
|
|
onAcceptWithDetails: (details) {
|
|
final Participant dp =
|
|
details.data['participant'] as Participant;
|
|
final Team from = details.data['fromTeam'] as Team;
|
|
if (onMoveParticipant != null) {
|
|
onMoveParticipant!(
|
|
dp,
|
|
from,
|
|
team,
|
|
team.memberIds.length,
|
|
);
|
|
}
|
|
},
|
|
builder: (context, candidateData, rejected) {
|
|
final highlight = candidateData.isNotEmpty;
|
|
return AnimatedContainer(
|
|
duration: const Duration(milliseconds: 150),
|
|
curve: Curves.easeOut,
|
|
height: highlight ? 16 : 8,
|
|
color: highlight
|
|
? Colors.blue.shade100
|
|
: Colors.transparent,
|
|
);
|
|
},
|
|
),
|
|
if (unassignedParticipants.isNotEmpty)
|
|
ListTile(
|
|
leading: const Icon(
|
|
Icons.add_circle,
|
|
color: Colors.green,
|
|
),
|
|
title: const Text('팀원 추가하기'),
|
|
onTap: () => onShowParticipantSelectionDialog(team),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _UnassignedChip extends StatelessWidget {
|
|
final Participant participant;
|
|
final List<Score> scores;
|
|
const _UnassignedChip({required this.participant, required this.scores});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
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;
|
|
|
|
int? hcp = participant.handicap;
|
|
if (hcp == null) {
|
|
final latest = scores
|
|
.where(
|
|
(s) => s.memberId == participant.memberId && s.handicap != null,
|
|
)
|
|
.fold<Score?>(
|
|
null,
|
|
(prev, s) => prev == null || s.date.isAfter(prev.date) ? s : prev,
|
|
);
|
|
hcp = latest?.handicap;
|
|
}
|
|
|
|
return Chip(
|
|
avatar: CircleAvatar(
|
|
backgroundColor: color.withOpacity(0.1),
|
|
child: Icon(icon, size: 16, color: color),
|
|
),
|
|
label: Row(
|
|
mainAxisSize: MainAxisSize.max,
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
participant.name ?? '알 수 없음',
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
const SizedBox(width: 4),
|
|
Flexible(
|
|
child: FittedBox(
|
|
fit: BoxFit.scaleDown,
|
|
alignment: Alignment.centerRight,
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
if (hcp != null && hcp != 0)
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 6,
|
|
vertical: 2,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: Colors.deepPurple.withOpacity(0.10),
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(
|
|
color: Colors.deepPurple.withOpacity(0.25),
|
|
),
|
|
),
|
|
child: Text(
|
|
hcp > 0 ? '+$hcp' : '$hcp',
|
|
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.withOpacity(0.10),
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(
|
|
color: Colors.indigo.withOpacity(0.25),
|
|
),
|
|
),
|
|
child: Text(
|
|
participant.average!.toStringAsFixed(1),
|
|
style: const TextStyle(
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w600,
|
|
color: Colors.indigo,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
|
);
|
|
}
|
|
}
|