이벤트 개발완료

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,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),
),
);
},
);
}
}