357 lines
14 KiB
Dart
357 lines
14 KiB
Dart
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),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|