이벤트 개발완료
This commit is contained in:
@@ -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),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user