이벤트 개발완료
This commit is contained in:
+28
-32
@@ -15,7 +15,7 @@ import 'screens/club/dashboard_screen.dart';
|
||||
import 'screens/club/members_screen.dart';
|
||||
import 'screens/club/events_screen.dart';
|
||||
import 'screens/club/club_settings_screen.dart';
|
||||
import 'screens/score/club_statistics_screen.dart';
|
||||
// import 'screens/score/club_statistics_screen.dart';
|
||||
import 'widgets/dialog_actions.dart';
|
||||
|
||||
// 전역 네비게이터 키 (인증 오류 처리용)
|
||||
@@ -171,12 +171,11 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
int _selectedIndex = 0;
|
||||
bool _isInit = false;
|
||||
|
||||
static final List<Widget> _widgetOptions = <Widget>[
|
||||
const DashboardScreen(),
|
||||
const MembersScreen(),
|
||||
const EventsScreen(),
|
||||
const ClubStatisticsScreen(),
|
||||
const ProfileScreen(),
|
||||
List<Widget> get _widgetOptions => const <Widget>[
|
||||
DashboardScreen(),
|
||||
MembersScreen(),
|
||||
EventsScreen(),
|
||||
ProfileScreen(),
|
||||
];
|
||||
|
||||
void _onItemTapped(int index) {
|
||||
@@ -367,28 +366,29 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
},
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.notifications),
|
||||
onPressed: () async {
|
||||
// 알림 권한 요청
|
||||
final notificationService = NotificationService();
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final hasPermission = await notificationService.requestPermission();
|
||||
if (hasPermission) {
|
||||
if (context.mounted) {
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(content: Text('알림 권한이 허용되었습니다')),
|
||||
);
|
||||
if (_selectedIndex == 0)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.notifications),
|
||||
onPressed: () async {
|
||||
// 알림 권한 요청 (대시보드에서만 노출)
|
||||
final notificationService = NotificationService();
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final hasPermission = await notificationService.requestPermission();
|
||||
if (hasPermission) {
|
||||
if (context.mounted) {
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(content: Text('알림 권한이 허용되었습니다')),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (context.mounted) {
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(content: Text('알림 권한이 거부되었습니다. 설정에서 권한을 허용해주세요')),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (context.mounted) {
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(content: Text('알림 권한이 거부되었습니다. 설정에서 권한을 허용해주세요')),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: _widgetOptions.elementAt(_selectedIndex),
|
||||
@@ -407,10 +407,6 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
icon: Icon(Icons.event),
|
||||
label: '이벤트',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.score),
|
||||
label: '통계',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.person),
|
||||
label: '프로필',
|
||||
|
||||
@@ -53,12 +53,14 @@ class Event {
|
||||
startDate: json['startDate'] != null ? DateTime.parse(json['startDate'].toString()) : DateTime.now(),
|
||||
endDate: json['endDate'] != null ? DateTime.parse(json['endDate'].toString()) : null,
|
||||
location: json['location']?.toString(),
|
||||
type: json['type']?.toString(),
|
||||
// 서버는 eventType 키를 사용할 수 있으므로 보강
|
||||
type: (json['type'] ?? json['eventType'])?.toString(),
|
||||
status: json['status']?.toString(),
|
||||
maxParticipants: json['maxParticipants'],
|
||||
currentParticipants: json['currentParticipants'],
|
||||
gameCount: json['gameCount'],
|
||||
participantFee: json['participantFee'] != null ? double.tryParse(json['participantFee'].toString()) : null,
|
||||
// 참가비: 서버가 "0"/"0.00" 등으로 기본화할 수 있어 0은 null로 간주
|
||||
participantFee: Event._parseParticipantFeeOrNull(json['participantFee']),
|
||||
registrationDeadline: json['registrationDeadline'] != null ? DateTime.parse(json['registrationDeadline'].toString()) : null,
|
||||
publicHash: json['publicHash']?.toString(),
|
||||
accessPassword: json['accessPassword']?.toString(),
|
||||
@@ -69,6 +71,19 @@ class Event {
|
||||
);
|
||||
}
|
||||
|
||||
// 참가비 파싱 헬퍼: null/빈문자열/0/"0.00" 모두 null로 간주
|
||||
static double? _parseParticipantFeeOrNull(dynamic value) {
|
||||
if (value == null) return null;
|
||||
final s = value.toString().trim();
|
||||
if (s.isEmpty) return null;
|
||||
final d = double.tryParse(s);
|
||||
if (d == null) return null;
|
||||
// 0도 유효한 값으로 취급하여 표시되도록 그대로 반환
|
||||
return d;
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Event 객체를 JSON으로 변환
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
|
||||
@@ -14,6 +14,7 @@ class Member {
|
||||
final String? memberType;
|
||||
final int? handicap;
|
||||
final String? status;
|
||||
final double? average;
|
||||
final DateTime? createdAt;
|
||||
final DateTime? updatedAt;
|
||||
final DateTime? birthDate; // 생년월일 추가
|
||||
@@ -34,6 +35,7 @@ class Member {
|
||||
this.memberType,
|
||||
this.handicap,
|
||||
this.status,
|
||||
this.average,
|
||||
this.createdAt,
|
||||
this.updatedAt,
|
||||
this.birthDate,
|
||||
@@ -57,6 +59,12 @@ class Member {
|
||||
memberType: json['memberType']?.toString(),
|
||||
handicap: json['handicap'] != null ? int.tryParse(json['handicap'].toString()) : null,
|
||||
status: json['status']?.toString(),
|
||||
average: () {
|
||||
final val = json['average'];
|
||||
if (val == null) return null;
|
||||
if (val is num) return val.toDouble();
|
||||
return double.tryParse(val.toString());
|
||||
}(),
|
||||
createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt'].toString()) : null,
|
||||
updatedAt: json['updatedAt'] != null ? DateTime.parse(json['updatedAt'].toString()) : null,
|
||||
birthDate: json['birthDate'] != null ? DateTime.parse(json['birthDate'].toString()) : null,
|
||||
@@ -81,6 +89,7 @@ class Member {
|
||||
'memberType': memberType,
|
||||
'handicap': handicap,
|
||||
'status': status,
|
||||
'average': average,
|
||||
'createdAt': createdAt?.toIso8601String(),
|
||||
'updatedAt': updatedAt?.toIso8601String(),
|
||||
'birthDate': birthDate?.toIso8601String(),
|
||||
@@ -104,6 +113,7 @@ class Member {
|
||||
String? memberType,
|
||||
int? handicap,
|
||||
String? status,
|
||||
double? average,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
DateTime? birthDate,
|
||||
@@ -124,6 +134,7 @@ class Member {
|
||||
memberType: memberType ?? this.memberType,
|
||||
handicap: handicap ?? this.handicap,
|
||||
status: status ?? this.status,
|
||||
average: average ?? this.average,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
birthDate: birthDate ?? this.birthDate,
|
||||
|
||||
@@ -16,6 +16,9 @@ class Participant {
|
||||
final DateTime? createdAt;
|
||||
final DateTime? updatedAt;
|
||||
String? memberType; // 회원 유형 필드 추가: '정회원', '준회원', '게스트' 등
|
||||
final String? gender; // 성별 (서버 Member.gender에서 유추)
|
||||
final int? handicap; // 핸디캡 (서버 Member.handicap에서 유추)
|
||||
final double? average; // 백엔드에서 제공하는 동적 에버리지(옵션)
|
||||
|
||||
Participant({
|
||||
required this.id,
|
||||
@@ -34,11 +37,60 @@ class Participant {
|
||||
this.notes,
|
||||
this.createdAt,
|
||||
this.updatedAt,
|
||||
this.gender,
|
||||
this.handicap,
|
||||
this.average,
|
||||
});
|
||||
|
||||
Participant copyWith({
|
||||
String? id,
|
||||
String? eventId,
|
||||
String? memberId,
|
||||
String? name,
|
||||
String? email,
|
||||
String? phoneNumber,
|
||||
String? status,
|
||||
DateTime? registeredAt,
|
||||
DateTime? confirmedAt,
|
||||
DateTime? cancelledAt,
|
||||
DateTime? attendedAt,
|
||||
bool? isPaid,
|
||||
double? paidAmount,
|
||||
String? notes,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
String? gender,
|
||||
int? handicap,
|
||||
double? average,
|
||||
String? memberType,
|
||||
}) {
|
||||
return Participant(
|
||||
id: id ?? this.id,
|
||||
eventId: eventId ?? this.eventId,
|
||||
memberId: memberId ?? this.memberId,
|
||||
name: name ?? this.name,
|
||||
email: email ?? this.email,
|
||||
phoneNumber: phoneNumber ?? this.phoneNumber,
|
||||
status: status ?? this.status,
|
||||
registeredAt: registeredAt ?? this.registeredAt,
|
||||
confirmedAt: confirmedAt ?? this.confirmedAt,
|
||||
cancelledAt: cancelledAt ?? this.cancelledAt,
|
||||
attendedAt: attendedAt ?? this.attendedAt,
|
||||
isPaid: isPaid ?? this.isPaid,
|
||||
paidAmount: paidAmount ?? this.paidAmount,
|
||||
notes: notes ?? this.notes,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
gender: gender ?? this.gender,
|
||||
handicap: handicap ?? this.handicap,
|
||||
average: average ?? this.average,
|
||||
// preserve mutable memberType even though not in ctor params order earlier
|
||||
)..memberType = memberType ?? this.memberType;
|
||||
}
|
||||
|
||||
// JSON 데이터로부터 Participant 객체 생성
|
||||
factory Participant.fromJson(Map<String, dynamic> json) {
|
||||
return Participant(
|
||||
final p = Participant(
|
||||
id: json['id']?.toString() ?? '',
|
||||
eventId: json['eventId']?.toString() ?? '',
|
||||
memberId: json['memberId']?.toString() ?? '',
|
||||
@@ -54,10 +106,29 @@ class Participant {
|
||||
// 서버는 paymentStatus('paid'|'unpaid')를 줄 수 있으므로 이를 isPaid로 변환
|
||||
isPaid: json['isPaid'] ?? (json['paymentStatus']?.toString().toLowerCase() == 'paid'),
|
||||
paidAmount: json['paidAmount'] != null ? double.tryParse(json['paidAmount'].toString()) : null,
|
||||
notes: json['notes']?.toString(),
|
||||
notes: (json['notes'] ?? json['comment'])?.toString(),
|
||||
createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt'].toString()) : null,
|
||||
updatedAt: json['updatedAt'] != null ? DateTime.parse(json['updatedAt'].toString()) : null,
|
||||
gender: (json['gender'] ?? json['Member']?['gender'])?.toString(),
|
||||
handicap: () {
|
||||
final val = json['handicap'] ?? json['Member']?['handicap'];
|
||||
if (val == null) return null;
|
||||
if (val is num) return val.toInt();
|
||||
return int.tryParse(val.toString());
|
||||
}(),
|
||||
average: () {
|
||||
final val = json['average'] ?? json['Member']?['average'];
|
||||
if (val == null) return null;
|
||||
if (val is num) return val.toDouble();
|
||||
return double.tryParse(val.toString());
|
||||
}(),
|
||||
);
|
||||
// 회원 유형은 원시 enum 값을 유지하고, 표시 단계에서 매핑
|
||||
final rawType = (json['memberType'] ?? json['Member']?['memberType'])?.toString();
|
||||
if (rawType != null && rawType.isNotEmpty) {
|
||||
p.memberType = rawType;
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
// Participant 객체를 JSON으로 변환
|
||||
@@ -77,8 +148,12 @@ class Participant {
|
||||
'isPaid': isPaid,
|
||||
'paidAmount': paidAmount,
|
||||
'notes': notes,
|
||||
'comment': notes,
|
||||
'createdAt': createdAt?.toIso8601String(),
|
||||
'updatedAt': updatedAt?.toIso8601String(),
|
||||
'gender': gender,
|
||||
'handicap': handicap,
|
||||
'average': average,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@ class Score {
|
||||
final String memberId;
|
||||
final String eventId;
|
||||
final String clubId;
|
||||
final String? participantId; // 참가자 ID (백엔드 EventScore 필드)
|
||||
final List<int> frames;
|
||||
final int totalScore;
|
||||
final int? handicap; // 핸디캡 추가
|
||||
final int? gameNumber; // 게임 번호 (백엔드 EventScore 필드)
|
||||
final DateTime date;
|
||||
final String? notes;
|
||||
final String? participantName;
|
||||
@@ -15,9 +17,11 @@ class Score {
|
||||
required this.memberId,
|
||||
required this.eventId,
|
||||
required this.clubId,
|
||||
this.participantId,
|
||||
required this.frames,
|
||||
required this.totalScore,
|
||||
this.handicap,
|
||||
this.gameNumber,
|
||||
required this.date,
|
||||
this.notes,
|
||||
this.participantName,
|
||||
@@ -32,9 +36,19 @@ class Score {
|
||||
memberId: json['memberId']?.toString() ?? '',
|
||||
eventId: json['eventId']?.toString() ?? '',
|
||||
clubId: json['clubId']?.toString() ?? '',
|
||||
participantId: json['participantId']?.toString(),
|
||||
frames: json['frames'] != null ? List<int>.from(json['frames']) : [],
|
||||
totalScore: json['totalScore'] ?? 0,
|
||||
// 서버는 base 점수를 'score'로, 합계(핸디 포함)를 'totalScore'로 반환할 수 있음.
|
||||
// 앱 내부에서는 base 점수를 totalScore 필드에 저장하여 일관되게 사용하고,
|
||||
// 핸디 포함 합계는 getter(totalWithHandicap)로 계산한다.
|
||||
totalScore: (json['score'] ?? json['totalScore']) ?? 0,
|
||||
handicap: json['handicap'],
|
||||
gameNumber: () {
|
||||
final v = json['gameNumber'];
|
||||
if (v == null) return null;
|
||||
if (v is int) return v;
|
||||
return int.tryParse(v.toString());
|
||||
}(),
|
||||
date: json['date'] != null ? DateTime.parse(json['date'].toString()) : DateTime.now(),
|
||||
notes: json['notes']?.toString(),
|
||||
participantName: json['participantName']?.toString(),
|
||||
@@ -42,7 +56,7 @@ class Score {
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
final map = {
|
||||
'id': id,
|
||||
'memberId': memberId,
|
||||
'eventId': eventId,
|
||||
@@ -50,10 +64,15 @@ class Score {
|
||||
'frames': frames,
|
||||
'totalScore': totalScore,
|
||||
'handicap': handicap,
|
||||
'gameNumber': gameNumber,
|
||||
'date': date.toIso8601String(),
|
||||
'notes': notes,
|
||||
'participantName': participantName,
|
||||
};
|
||||
if (participantId != null) {
|
||||
map['participantId'] = participantId;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -44,7 +44,8 @@ class _ClubSettingsScreenState extends State<ClubSettingsScreen> {
|
||||
_isLoading = false;
|
||||
_hasEditPermission = true; // 테스트에서는 편집 가능 상태로 둠
|
||||
} else {
|
||||
_loadData();
|
||||
// 첫 프레임 이후에 로드하여 InheritedWidget 접근 안전 보장
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _loadData());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,8 +63,6 @@ class _ClubSettingsScreenState extends State<ClubSettingsScreen> {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
// await 이전에 messenger 캡처 (catch에서 사용)
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
// await 이전에 authService 캡처 (이후 권한 계산에 사용)
|
||||
final authService = Provider.of<AuthService>(context, listen: false);
|
||||
|
||||
@@ -115,7 +114,8 @@ class _ClubSettingsScreenState extends State<ClubSettingsScreen> {
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
messenger.showSnackBar(
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('데이터 로드 중 오류가 발생했습니다: $error')),
|
||||
);
|
||||
} finally {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,898 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../services/event_service.dart';
|
||||
import '../../services/member_service.dart';
|
||||
|
||||
class EventImportWizard extends StatefulWidget {
|
||||
const EventImportWizard({super.key});
|
||||
|
||||
@override
|
||||
State<EventImportWizard> createState() => _EventImportWizardState();
|
||||
}
|
||||
|
||||
class _EventImportWizardState extends State<EventImportWizard> {
|
||||
int _currentStep = 0;
|
||||
|
||||
// Step1: upload
|
||||
File? _pickedFile;
|
||||
String? _uploadedFilename;
|
||||
bool _uploading = false;
|
||||
|
||||
// Step2: preview
|
||||
Map<String, dynamic>?
|
||||
_previewData; // { participants: [], suggestions: {}, ... }
|
||||
List<Map<String, dynamic>> _participants = [];
|
||||
bool _loadingPreview = false;
|
||||
String? _sheetName;
|
||||
bool _scoresIncludeHandicap = true; // 미리보기: 점수에 핸디 포함 여부
|
||||
|
||||
// Step3: event info
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _titleCtrl = TextEditingController();
|
||||
final _locationCtrl = TextEditingController();
|
||||
DateTime? _startDate;
|
||||
DateTime? _endDate;
|
||||
int _gameCount = 3;
|
||||
|
||||
// Helpers
|
||||
Widget _buildMessageList(
|
||||
dynamic messages, {
|
||||
required String title,
|
||||
required Color color,
|
||||
}) {
|
||||
final List<String> list;
|
||||
if (messages is List) {
|
||||
list = messages.map((e) => e.toString()).toList();
|
||||
} else if (messages is Map) {
|
||||
list = messages.values.map((e) => e.toString()).toList();
|
||||
} else if (messages != null) {
|
||||
list = [messages.toString()];
|
||||
} else {
|
||||
list = const [];
|
||||
}
|
||||
if (list.isEmpty) return const SizedBox.shrink();
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.08),
|
||||
border: Border.all(color: color.withOpacity(0.5)),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, color: color, size: 18),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(color: color, fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
...list.map(
|
||||
(m) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Text('- $m'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openMemberPicker(Map<String, dynamic> p) async {
|
||||
final memberService = Provider.of<MemberService>(context, listen: false);
|
||||
await showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (ctx) {
|
||||
return SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.person_off),
|
||||
title: const Text('게스트'),
|
||||
onTap: () {
|
||||
setState(() {
|
||||
p['memberId'] = null;
|
||||
});
|
||||
Navigator.of(ctx).pop();
|
||||
},
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: memberService.members.length,
|
||||
itemBuilder: (_, i) {
|
||||
final m = memberService.members[i];
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.person),
|
||||
title: Text(m.name, overflow: TextOverflow.ellipsis),
|
||||
onTap: () {
|
||||
setState(() {
|
||||
p['memberId'] = m.id;
|
||||
p['name'] = m.name.length > 5
|
||||
? m.name.substring(0, 5)
|
||||
: m.name;
|
||||
});
|
||||
Navigator.of(ctx).pop();
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _normalizeScoresForAll() {
|
||||
setState(() {
|
||||
for (final p in _participants) {
|
||||
final list = <Map<String, dynamic>>[];
|
||||
final cur = p['scores'];
|
||||
if (cur is List) {
|
||||
for (var i = 0; i < cur.length && i < _gameCount; i++) {
|
||||
final s = cur[i];
|
||||
if (s is num) {
|
||||
list.add({'gameNumber': i + 1, 'score': s.toInt()});
|
||||
} else if (s is Map) {
|
||||
list.add({
|
||||
'gameNumber': (s['gameNumber'] ?? (i + 1)) is num
|
||||
? (s['gameNumber'] as num).toInt()
|
||||
: (i + 1),
|
||||
'score': int.tryParse((s['score'] ?? 0).toString()) ?? 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
// pad zeros to reach gameCount
|
||||
for (var i = list.length; i < _gameCount; i++) {
|
||||
list.add({'gameNumber': i + 1, 'score': 0});
|
||||
}
|
||||
p['scores'] = list;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
List<String> _rowWarnings(Map<String, dynamic> p) {
|
||||
final w = <String>[];
|
||||
final name = (p['name']?.toString() ?? '').trim();
|
||||
if (name.isEmpty) w.add('이름 누락');
|
||||
final scores = p['scores'];
|
||||
if (scores is List) {
|
||||
final len = scores.length;
|
||||
if (_gameCount > 0 && len != _gameCount) {
|
||||
w.add('점수 개수($_gameCount 기대) 불일치: $len');
|
||||
}
|
||||
}
|
||||
return w;
|
||||
}
|
||||
|
||||
Future<bool> _confirmAndDeleteTempIfAny() async {
|
||||
if (_uploadedFilename == null) return true;
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) => AlertDialog(
|
||||
title: const Text('가져오기 취소'),
|
||||
content: const Text('업로드된 임시 파일을 삭제하고 마법사를 종료할까요?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('유지'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('삭제 후 종료'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed == true) {
|
||||
try {
|
||||
final svc = Provider.of<EventService>(context, listen: false);
|
||||
await svc.deleteUploadedFile(_uploadedFilename!);
|
||||
} catch (_) {
|
||||
// ignore deletion failure
|
||||
}
|
||||
}
|
||||
return confirmed ?? false;
|
||||
}
|
||||
|
||||
Future<void> _pickAndUpload() async {
|
||||
final eventService = Provider.of<EventService>(context, listen: false);
|
||||
setState(() => _uploading = true);
|
||||
try {
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: [
|
||||
'xlsx',
|
||||
'xls',
|
||||
'csv',
|
||||
'png',
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'heic',
|
||||
'webp',
|
||||
],
|
||||
);
|
||||
if (result == null || result.files.isEmpty) {
|
||||
setState(() => _uploading = false);
|
||||
return;
|
||||
}
|
||||
final path = result.files.first.path;
|
||||
if (path == null) {
|
||||
setState(() => _uploading = false);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('파일 경로를 확인할 수 없습니다.')));
|
||||
return;
|
||||
}
|
||||
_pickedFile = File(path);
|
||||
final resp = await eventService.uploadEventFile(_pickedFile!);
|
||||
_uploadedFilename = resp['filename']?.toString();
|
||||
if (_uploadedFilename == null) {
|
||||
throw Exception('서버 응답에 filename이 없습니다');
|
||||
}
|
||||
setState(() {});
|
||||
await _loadPreview();
|
||||
setState(() => _currentStep = 1);
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('업로드 실패: $e')));
|
||||
} finally {
|
||||
setState(() => _uploading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadPreview() async {
|
||||
final eventService = Provider.of<EventService>(context, listen: false);
|
||||
final memberService = Provider.of<MemberService>(context, listen: false);
|
||||
if (_uploadedFilename == null) return;
|
||||
setState(() => _loadingPreview = true);
|
||||
try {
|
||||
final data = await eventService.parseEventFile(
|
||||
filename: _uploadedFilename!,
|
||||
sheetName: _sheetName,
|
||||
);
|
||||
_previewData = data;
|
||||
final list = (data['participants'] as List?) ?? [];
|
||||
_participants = list
|
||||
.map((e) => Map<String, dynamic>.from(e as Map))
|
||||
.toList();
|
||||
// ensure members loaded (no heavy await to keep UX; if already loaded, members is non-empty)
|
||||
if (memberService.members.isEmpty) {
|
||||
try {
|
||||
await memberService.fetchClubMembers();
|
||||
} catch (_) {}
|
||||
}
|
||||
// auto-match by exact trimmed name
|
||||
final Map<String, String> nameToId = {
|
||||
for (final m in memberService.members) m.name.trim(): m.id,
|
||||
};
|
||||
for (final p in _participants) {
|
||||
final nm = (p['name']?.toString() ?? '').trim();
|
||||
if (nm.isNotEmpty && nameToId.containsKey(nm)) {
|
||||
p['memberId'] = nameToId[nm];
|
||||
}
|
||||
}
|
||||
// event suggestion
|
||||
final event = Map<String, dynamic>.from((data['event'] as Map?) ?? {});
|
||||
_titleCtrl.text = event['title']?.toString() ?? _titleCtrl.text;
|
||||
_locationCtrl.text = event['location']?.toString() ?? _locationCtrl.text;
|
||||
_gameCount =
|
||||
int.tryParse(event['gameCount']?.toString() ?? '') ?? _gameCount;
|
||||
final sd = event['startDate']?.toString();
|
||||
final ed = event['endDate']?.toString();
|
||||
if (sd != null) _startDate = DateTime.tryParse(sd);
|
||||
if (ed != null) _endDate = DateTime.tryParse(ed);
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('미리보기 로드 실패: $e')));
|
||||
} finally {
|
||||
setState(() => _loadingPreview = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveAll() async {
|
||||
if (!_formKey.currentState!.validate()) {
|
||||
return;
|
||||
}
|
||||
final eventService = Provider.of<EventService>(context, listen: false);
|
||||
try {
|
||||
final eventData = {
|
||||
'title': _titleCtrl.text.trim(),
|
||||
'location': _locationCtrl.text.trim().isEmpty
|
||||
? null
|
||||
: _locationCtrl.text.trim(),
|
||||
'startDate': _startDate ?? DateTime.now(),
|
||||
'endDate': _endDate,
|
||||
'gameCount': _gameCount,
|
||||
'status': 'draft',
|
||||
'eventType': 'regular',
|
||||
// 서버에 미리보기에서 선택한 핸디 포함 여부 전달
|
||||
'scoresIncludeHandicap': _scoresIncludeHandicap,
|
||||
};
|
||||
// participants minimal fields: name, gender, teamNumber, scores, handicap
|
||||
final payloadParticipants = _participants
|
||||
.map(
|
||||
(p) => {
|
||||
'name': p['name'],
|
||||
if (p['memberId'] != null) 'memberId': p['memberId'],
|
||||
if (p['teamNumber'] != null) 'teamNumber': p['teamNumber'],
|
||||
if (p['handicap'] != null) 'handicap': p['handicap'],
|
||||
if (p['scores'] != null) 'scores': p['scores'],
|
||||
},
|
||||
)
|
||||
.toList();
|
||||
|
||||
final resp = await eventService.saveEventFileData(
|
||||
eventData: eventData,
|
||||
participants: payloadParticipants,
|
||||
);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('이벤트가 저장되었습니다.')),
|
||||
);
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pop(resp);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('저장 실패: $e')));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pickDate({required bool isStart}) async {
|
||||
final now = DateTime.now();
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: (isStart ? _startDate : _endDate) ?? now,
|
||||
firstDate: DateTime(now.year - 5),
|
||||
lastDate: DateTime(now.year + 5),
|
||||
);
|
||||
if (picked == null) return;
|
||||
setState(() {
|
||||
if (isStart) {
|
||||
_startDate = picked;
|
||||
} else {
|
||||
_endDate = picked;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_titleCtrl.dispose();
|
||||
_locationCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final dateFmt = DateFormat('yyyy-MM-dd');
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('이벤트 가져오기')),
|
||||
body: Stepper(
|
||||
currentStep: _currentStep,
|
||||
onStepContinue: () async {
|
||||
if (_currentStep == 0) {
|
||||
await _pickAndUpload();
|
||||
} else if (_currentStep == 1) {
|
||||
// allow proceed; editing continues in next step
|
||||
setState(() => _currentStep = 2);
|
||||
} else if (_currentStep == 2) {
|
||||
// block if any participant missing name
|
||||
final missing = _participants.any(
|
||||
(p) => (p['name']?.toString() ?? '').trim().isEmpty,
|
||||
);
|
||||
if (missing) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('이름이 비어있는 참가자가 있습니다. 미리보기에서 수정해 주세요.'),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (_formKey.currentState!.validate()) {
|
||||
setState(() => _currentStep = 3);
|
||||
}
|
||||
} else if (_currentStep == 3) {
|
||||
await _saveAll();
|
||||
}
|
||||
},
|
||||
onStepCancel: () async {
|
||||
if (_currentStep > 0) {
|
||||
setState(() => _currentStep -= 1);
|
||||
} else {
|
||||
final ok = await _confirmAndDeleteTempIfAny();
|
||||
if (ok) {
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
},
|
||||
steps: [
|
||||
Step(
|
||||
title: const Text('파일 업로드'),
|
||||
isActive: _currentStep >= 0,
|
||||
content: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_pickedFile != null
|
||||
? '선택됨: ${_pickedFile!.path.split('/').last}'
|
||||
: '파일을 선택하세요.',
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _uploading ? null : _pickAndUpload,
|
||||
icon: const Icon(Icons.file_upload),
|
||||
label: Text(_uploading ? '업로드 중...' : '파일 선택 및 업로드'),
|
||||
),
|
||||
if (_uploadedFilename != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: Text('업로드됨: $_uploadedFilename'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Step(
|
||||
title: const Text('미리보기/수정'),
|
||||
isActive: _currentStep >= 1,
|
||||
state: _currentStep > 1 ? StepState.complete : StepState.indexed,
|
||||
content: _loadingPreview
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text('참가자 ${_participants.length}명'),
|
||||
),
|
||||
Flexible(
|
||||
child: Wrap(
|
||||
alignment: WrapAlignment.end,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Checkbox(
|
||||
value: _scoresIncludeHandicap,
|
||||
onChanged: (v) {
|
||||
if (v == null) return;
|
||||
setState(() => _scoresIncludeHandicap = v);
|
||||
},
|
||||
),
|
||||
const Text('점수에 핸디 포함'),
|
||||
],
|
||||
),
|
||||
if ((_previewData?['sheets'] is List) &&
|
||||
((_previewData?['sheets'] as List).length > 1))
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 180),
|
||||
child: DropdownButton<String>(
|
||||
isExpanded: true,
|
||||
value: _sheetName ??
|
||||
((_previewData!['sheets'] as List).first?.toString()),
|
||||
hint: const Text('시트 선택'),
|
||||
items: (_previewData!['sheets'] as List)
|
||||
.map<DropdownMenuItem<String>>(
|
||||
(s) => DropdownMenuItem(
|
||||
value: s.toString(),
|
||||
child: Text(s.toString(), overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: (v) async {
|
||||
setState(() => _sheetName = v);
|
||||
await _loadPreview();
|
||||
},
|
||||
),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: _participants.isEmpty
|
||||
? null
|
||||
: _normalizeScoresForAll,
|
||||
icon: const Icon(Icons.tune, size: 16),
|
||||
label: const Text('점수 개수 맞추기'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
// Server-side warnings/errors if present
|
||||
if ((_previewData?['warnings'] != null) ||
|
||||
(_previewData?['errors'] != null))
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (_previewData?['warnings'] != null)
|
||||
_buildMessageList(
|
||||
_previewData!['warnings'],
|
||||
title: '서버 경고',
|
||||
color: Colors.orange,
|
||||
),
|
||||
if (_previewData?['errors'] != null)
|
||||
_buildMessageList(
|
||||
_previewData!['errors'],
|
||||
title: '서버 오류',
|
||||
color: Colors.red,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final totalWarnings = _participants
|
||||
.map(_rowWarnings)
|
||||
.fold<int>(0, (a, b) => a + b.length);
|
||||
if (totalWarnings == 0)
|
||||
return const SizedBox.shrink();
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
top: 4.0,
|
||||
bottom: 4.0,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.warning_amber_rounded,
|
||||
color: Colors.orange,
|
||||
size: 18,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'경고 ${totalWarnings}건: 이름 누락 또는 점수 개수 불일치',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ListView.separated(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemBuilder: (_, i) {
|
||||
final p = _participants[i];
|
||||
final warns = _rowWarnings(p);
|
||||
return Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 4,
|
||||
child: TextFormField(
|
||||
initialValue: p['name']?.toString() ?? '',
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
labelText: '이름',
|
||||
counterText: '',
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(
|
||||
vertical: 8,
|
||||
horizontal: 12,
|
||||
),
|
||||
suffixIcon: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (p['memberId'] == null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
right: 4,
|
||||
),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange
|
||||
.withOpacity(0.15),
|
||||
borderRadius:
|
||||
BorderRadius.circular(
|
||||
4,
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
vertical: 2,
|
||||
),
|
||||
child: Text(
|
||||
'게',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
Icons.person_search,
|
||||
),
|
||||
tooltip: '회원 선택',
|
||||
onPressed: () =>
|
||||
_openMemberPicker(p),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
maxLength: 5,
|
||||
maxLines: 1,
|
||||
textAlignVertical:
|
||||
TextAlignVertical.center,
|
||||
onChanged: (v) {
|
||||
final t = v
|
||||
.replaceAll('(게스트)', '')
|
||||
.trim();
|
||||
p['name'] = t;
|
||||
p['memberId'] = null;
|
||||
},
|
||||
textInputAction: TextInputAction.next,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
width: 64,
|
||||
child: TextFormField(
|
||||
initialValue:
|
||||
(p['teamNumber']?.toString() ?? ''),
|
||||
decoration: const InputDecoration(
|
||||
isDense: true,
|
||||
labelText: '팀',
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
onChanged: (v) =>
|
||||
p['teamNumber'] = int.tryParse(v),
|
||||
textInputAction: TextInputAction.next,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
width: 92,
|
||||
child: TextFormField(
|
||||
initialValue:
|
||||
(p['handicap']?.toString() ?? ''),
|
||||
decoration: const InputDecoration(
|
||||
isDense: true,
|
||||
labelText: '핸디캡',
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
onChanged: (v) =>
|
||||
p['handicap'] = int.tryParse(v) ?? 0,
|
||||
textInputAction: TextInputAction.next,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Builder(
|
||||
builder: (_) {
|
||||
final scoresMap = <int, int>{};
|
||||
final s = p['scores'];
|
||||
if (s is List) {
|
||||
for (final e in s) {
|
||||
if (e is num) {
|
||||
final idx = scoresMap.length + 1;
|
||||
scoresMap[idx] = e.toInt();
|
||||
} else if (e is Map) {
|
||||
final gn =
|
||||
int.tryParse(
|
||||
(e['gameNumber'] ?? 0).toString(),
|
||||
) ??
|
||||
0;
|
||||
final sc =
|
||||
int.tryParse(
|
||||
(e['score'] ?? 0).toString(),
|
||||
) ??
|
||||
0;
|
||||
if (gn > 0) scoresMap[gn] = sc;
|
||||
}
|
||||
}
|
||||
}
|
||||
final fields = <Widget>[];
|
||||
for (var i = 1; i <= _gameCount; i++) {
|
||||
final initial =
|
||||
scoresMap[i]?.toString() ?? '';
|
||||
fields.add(
|
||||
SizedBox(
|
||||
width: 64,
|
||||
child: TextFormField(
|
||||
initialValue: initial,
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
labelText: '${i}G',
|
||||
counterText: '',
|
||||
),
|
||||
maxLength: 3,
|
||||
keyboardType: TextInputType.number,
|
||||
onChanged: (v) {
|
||||
final cur =
|
||||
<Map<String, dynamic>>[];
|
||||
for (
|
||||
var j = 1;
|
||||
j <= _gameCount;
|
||||
j++
|
||||
) {
|
||||
if (j == i) {
|
||||
final n = int.tryParse(v) ?? 0;
|
||||
cur.add({
|
||||
'gameNumber': j,
|
||||
'score': n,
|
||||
});
|
||||
} else {
|
||||
final prev = scoresMap[j] ?? 0;
|
||||
cur.add({
|
||||
'gameNumber': j,
|
||||
'score': prev,
|
||||
});
|
||||
}
|
||||
}
|
||||
p['scores'] = cur;
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: fields,
|
||||
);
|
||||
},
|
||||
),
|
||||
if (warns.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Wrap(
|
||||
spacing: 6,
|
||||
children: warns
|
||||
.map(
|
||||
(t) => Chip(
|
||||
label: Text(t),
|
||||
visualDensity:
|
||||
VisualDensity.compact,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
),
|
||||
backgroundColor:
|
||||
Colors.orange.shade100,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
separatorBuilder: (_, __) => const Divider(height: 12),
|
||||
itemCount: _participants.length,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Step(
|
||||
title: const Text('이벤트 정보'),
|
||||
isActive: _currentStep >= 2,
|
||||
state: _currentStep > 2 ? StepState.complete : StepState.indexed,
|
||||
content: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _titleCtrl,
|
||||
decoration: const InputDecoration(labelText: '이벤트 제목'),
|
||||
validator: (v) =>
|
||||
(v == null || v.trim().isEmpty) ? '제목을 입력하세요' : null,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextFormField(
|
||||
controller: _locationCtrl,
|
||||
decoration: const InputDecoration(labelText: '장소 (선택)'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: InkWell(
|
||||
onTap: () => _pickDate(isStart: true),
|
||||
child: InputDecorator(
|
||||
decoration: const InputDecoration(labelText: '시작일'),
|
||||
child: Text(
|
||||
_startDate != null
|
||||
? dateFmt.format(_startDate!)
|
||||
: '선택',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: InkWell(
|
||||
onTap: () => _pickDate(isStart: false),
|
||||
child: InputDecorator(
|
||||
decoration: const InputDecoration(
|
||||
labelText: '종료일 (선택)',
|
||||
),
|
||||
child: Text(
|
||||
_endDate != null
|
||||
? dateFmt.format(_endDate!)
|
||||
: '선택',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
const Text('게임 수'),
|
||||
const SizedBox(width: 12),
|
||||
DropdownButton<int>(
|
||||
value: _gameCount,
|
||||
items: const [1, 2, 3, 4, 5, 6]
|
||||
.map(
|
||||
(e) =>
|
||||
DropdownMenuItem(value: e, child: Text('$e')),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: (v) =>
|
||||
setState(() => _gameCount = v ?? _gameCount),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Step(
|
||||
title: const Text('확인 및 저장'),
|
||||
isActive: _currentStep >= 3,
|
||||
state: _currentStep == 3 ? StepState.editing : StepState.indexed,
|
||||
content: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('이벤트: ${_titleCtrl.text} (${_gameCount}게임)'),
|
||||
if (_startDate != null)
|
||||
Text('시작일: ${dateFmt.format(_startDate!)}'),
|
||||
if (_endDate != null) Text('종료일: ${dateFmt.format(_endDate!)}'),
|
||||
Text('참가자: ${_participants.length}명'),
|
||||
const SizedBox(height: 12),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _saveAll,
|
||||
icon: const Icon(Icons.save),
|
||||
label: const Text('저장'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
|
||||
import '../../utils/event_excel_parser.dart';
|
||||
import '../../utils/event_csv_parser.dart';
|
||||
@@ -17,6 +15,7 @@ import '../../widgets/loading_indicator.dart';
|
||||
import 'event_details_screen.dart';
|
||||
import 'event_form_screen.dart';
|
||||
import 'event_calendar_screen.dart';
|
||||
import 'event_import_wizard.dart';
|
||||
import '../../widgets/dialog_actions.dart';
|
||||
import '../../widgets/import_result_dialog.dart';
|
||||
import '../../theme/badges.dart';
|
||||
@@ -37,6 +36,23 @@ class _EventsScreenState extends State<EventsScreen> {
|
||||
String _filterStatus = '모든 상태'; // '모든 상태', '활성', '대기', '취소', '완료'
|
||||
String _sortBy = '날짜순'; // '날짜순', '이름순'
|
||||
|
||||
// 이벤트 상태 정규화: DB 원시값(영문) -> 한국어 라벨
|
||||
String? _normalizeEventStatus(String? status) {
|
||||
if (status == null) return null;
|
||||
final s = status.toLowerCase().trim();
|
||||
if (s == 'active' || s == 'enabled' || s == '활성') return '활성';
|
||||
if (s == 'pending' ||
|
||||
s == 'draft' ||
|
||||
s == 'scheduled' ||
|
||||
s == 'waiting' ||
|
||||
s == '대기')
|
||||
return '대기';
|
||||
if (s == 'canceled' || s == 'cancelled' || s == '취소') return '취소';
|
||||
if (s == 'completed' || s == 'done' || s == 'finished' || s == '완료')
|
||||
return '완료';
|
||||
return status; // 알 수 없는 값은 원문 유지
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
@@ -83,29 +99,32 @@ class _EventsScreenState extends State<EventsScreen> {
|
||||
navigator.pop();
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('파일 파싱 중 오류가 발생했습니다: ${e.toString()}\n가능하면 CSV 또는 XLSX 형식을 사용해 주세요.'),
|
||||
content: Text(
|
||||
'파일 파싱 중 오류가 발생했습니다: ${e.toString()}\n가능하면 CSV 또는 XLSX 형식을 사용해 주세요.',
|
||||
),
|
||||
duration: Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
final processedRows = parseResult['processedRows'] as int;
|
||||
final events = (parseResult['validEvents'] as List).cast<Map<String, dynamic>>();
|
||||
final events = (parseResult['validEvents'] as List)
|
||||
.cast<Map<String, dynamic>>();
|
||||
final invalidRows = parseResult['invalidRows'] as int;
|
||||
final invalidRowIndices = (parseResult['invalidRowIndices'] as List<dynamic>? ?? const [])
|
||||
.map((e) => e.toString())
|
||||
.toList();
|
||||
final Map<String, String> invalidRowReasons = ((parseResult['invalidRowReasons'] as Map?) ?? const {})
|
||||
.map((key, value) => MapEntry(key.toString(), value?.toString() ?? ''));
|
||||
final invalidRowIndices =
|
||||
(parseResult['invalidRowIndices'] as List<dynamic>? ?? const [])
|
||||
.map((e) => e.toString())
|
||||
.toList();
|
||||
final Map<String, String> invalidRowReasons =
|
||||
((parseResult['invalidRowReasons'] as Map?) ?? const {}).map(
|
||||
(key, value) => MapEntry(key.toString(), value?.toString() ?? ''),
|
||||
);
|
||||
final error = parseResult['error'] as String?;
|
||||
|
||||
if (error != null) {
|
||||
navigator.pop();
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(error),
|
||||
duration: Duration(seconds: 4),
|
||||
),
|
||||
SnackBar(content: Text(error), duration: Duration(seconds: 4)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -205,15 +224,11 @@ class _EventsScreenState extends State<EventsScreen> {
|
||||
eventService.setClubId(event.clubId);
|
||||
await eventService.cloneEvent(event.id);
|
||||
if (!mounted) return;
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(content: Text('이벤트가 복제되었습니다')),
|
||||
);
|
||||
messenger.showSnackBar(const SnackBar(content: Text('이벤트가 복제되었습니다')));
|
||||
// 목록 새로고침
|
||||
await _loadEvents();
|
||||
} catch (e) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('이벤트 복제에 실패했습니다: $e')),
|
||||
);
|
||||
messenger.showSnackBar(SnackBar(content: Text('이벤트 복제에 실패했습니다: $e')));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,6 +242,18 @@ class _EventsScreenState extends State<EventsScreen> {
|
||||
}
|
||||
|
||||
Future<void> _loadEvents() async {
|
||||
// 인증 가드: 로그인 상태가 아니면 목록 로딩을 시도하지 않음
|
||||
final authService = Provider.of<AuthService>(context, listen: false);
|
||||
if (!authService.isAuthenticated) {
|
||||
// 필요 시 간단 안내만 표시하고 리턴
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('로그인이 필요합니다. 로그인 후 이벤트를 불러옵니다.')),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
@@ -234,7 +261,6 @@ class _EventsScreenState extends State<EventsScreen> {
|
||||
// 비동기 갭 이후 컨텍스트 직접 참조를 피하기 위해 메신저 캡처
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
try {
|
||||
final authService = Provider.of<AuthService>(context, listen: false);
|
||||
final clubService = Provider.of<ClubService>(context, listen: false);
|
||||
final eventService = Provider.of<EventService>(context, listen: false);
|
||||
|
||||
@@ -244,9 +270,19 @@ class _EventsScreenState extends State<EventsScreen> {
|
||||
}
|
||||
} catch (e) {
|
||||
if (!context.mounted) return;
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('이벤트 목록을 불러오는데 실패했습니다: $e')),
|
||||
);
|
||||
final msg = e.toString();
|
||||
// 401/인증 관련 메시지에 대한 UX 보강
|
||||
if (msg.contains('401') || msg.contains('인증') || msg.contains('토큰')) {
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('세션이 만료되었거나 인증 정보가 유효하지 않습니다. 다시 로그인해 주세요.'),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('이벤트 목록을 불러오는데 실패했습니다: $e')),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
@@ -283,7 +319,9 @@ class _EventsScreenState extends State<EventsScreen> {
|
||||
// 상태 필터링
|
||||
if (_filterStatus != '모든 상태') {
|
||||
filteredEvents = filteredEvents
|
||||
.where((event) => event.status == _filterStatus)
|
||||
.where(
|
||||
(event) => _normalizeEventStatus(event.status) == _filterStatus,
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@@ -300,38 +338,30 @@ class _EventsScreenState extends State<EventsScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('이벤트'),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: '캘린더 보기',
|
||||
icon: const Icon(Icons.calendar_today),
|
||||
onPressed: _navigateToCalendarView,
|
||||
),
|
||||
IconButton(
|
||||
tooltip: '파일에서 생성',
|
||||
icon: const Icon(Icons.file_upload),
|
||||
onPressed: _showFileUploadDialog,
|
||||
),
|
||||
IconButton(
|
||||
tooltip: '이벤트 추가',
|
||||
icon: const Icon(Icons.add),
|
||||
onPressed: _showAddEventDialog,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: _isLoading
|
||||
? const LoadingIndicator()
|
||||
: Column(
|
||||
children: [
|
||||
// 상단 버튼 영역
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16.0, 16.0, 16.0, 0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
onPressed: _navigateToCalendarView,
|
||||
icon: const Icon(Icons.calendar_today),
|
||||
label: const Text('캘린더 보기'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Colors.blue,
|
||||
side: const BorderSide(color: Colors.blue),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _showFileUploadDialog,
|
||||
icon: const Icon(Icons.file_upload),
|
||||
label: const Text('파일에서 생성'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Colors.blue,
|
||||
side: const BorderSide(color: Colors.blue),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 검색 바
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
@@ -470,11 +500,7 @@ class _EventsScreenState extends State<EventsScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: _showAddEventDialog,
|
||||
backgroundColor: Colors.blue,
|
||||
child: const Icon(Icons.add, color: Colors.white),
|
||||
),
|
||||
// FAB 제거: AppBar actions로 이동
|
||||
);
|
||||
}
|
||||
|
||||
@@ -563,9 +589,7 @@ class _EventsScreenState extends State<EventsScreen> {
|
||||
Wrap(
|
||||
spacing: 4,
|
||||
runSpacing: 4,
|
||||
children: [
|
||||
BadgeStyles.eventStatus(event.status!),
|
||||
],
|
||||
children: [BadgeStyles.eventStatus(event.status!)],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
@@ -596,6 +620,8 @@ class _EventsScreenState extends State<EventsScreen> {
|
||||
_showDeleteConfirmationDialog(event);
|
||||
} else if (value == 'duplicate') {
|
||||
_duplicateEvent(event);
|
||||
} else if (value == 'permanent_delete') {
|
||||
_showPermanentDeleteConfirmationDialog(event);
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
@@ -623,9 +649,19 @@ class _EventsScreenState extends State<EventsScreen> {
|
||||
value: 'delete',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.delete, size: 18, color: Colors.red),
|
||||
Icon(Icons.visibility_off, size: 18),
|
||||
SizedBox(width: 8),
|
||||
Text('삭제', style: TextStyle(color: Colors.red)),
|
||||
Text('숨김'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const PopupMenuItem<String>(
|
||||
value: 'permanent_delete',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.delete_forever, size: 18, color: Colors.red),
|
||||
SizedBox(width: 8),
|
||||
Text('완전 삭제', style: TextStyle(color: Colors.red)),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -741,9 +777,9 @@ class _EventsScreenState extends State<EventsScreen> {
|
||||
onConfirm: () async {
|
||||
if (titleController.text.isEmpty) {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('제목은 필수 입력 항목입니다')),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('제목은 필수 입력 항목입니다')));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -775,14 +811,14 @@ class _EventsScreenState extends State<EventsScreen> {
|
||||
|
||||
if (!context.mounted) return;
|
||||
Navigator.of(context).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('이벤트가 업데이트되었습니다')),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('이벤트가 업데이트되었습니다')));
|
||||
} catch (e) {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('이벤트 업데이트에 실패했습니다: $e')),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('이벤트 업데이트에 실패했습니다: $e')));
|
||||
}
|
||||
},
|
||||
confirmText: '저장',
|
||||
@@ -810,8 +846,8 @@ class _EventsScreenState extends State<EventsScreen> {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('이벤트 삭제'),
|
||||
content: Text('${event.title} 이벤트를 정말 삭제하시겠습니까?'),
|
||||
title: const Text('이벤트 숨김'),
|
||||
content: Text('${event.title} 이벤트를 목록에서 숨길까요? (소프트 삭제)'),
|
||||
actions: DialogActions.confirm(
|
||||
onCancel: () => Navigator.of(context).pop(),
|
||||
onConfirm: () async {
|
||||
@@ -823,273 +859,79 @@ class _EventsScreenState extends State<EventsScreen> {
|
||||
await eventService.deleteEvent(event.id);
|
||||
if (!context.mounted) return;
|
||||
Navigator.of(context).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('이벤트가 삭제되었습니다')),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('이벤트가 숨김 처리되었습니다')));
|
||||
} catch (e) {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('이벤트 삭제에 실패했습니다: $e')),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('이벤트 숨김에 실패했습니다: $e')));
|
||||
}
|
||||
},
|
||||
destructive: true,
|
||||
confirmText: '삭제',
|
||||
destructive: false,
|
||||
confirmText: '숨김',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 캘린더 보기 화면으로 이동
|
||||
void _navigateToCalendarView() {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (context) => const EventCalendarScreen()),
|
||||
);
|
||||
}
|
||||
|
||||
// 파일에서 이벤트 생성 다이얼로그 표시
|
||||
void _showFileUploadDialog() {
|
||||
void _showPermanentDeleteConfirmationDialog(Event event) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('파일에서 이벤트 생성'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('엑셀 파일(.xlsx, .xls)에서 이벤트를 일괄 생성할 수 있습니다.'),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'파일 형식 안내:',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'• 첫 번째 행은 헤더로 사용됩니다.',
|
||||
style: TextStyle(fontSize: 14),
|
||||
),
|
||||
const Text(
|
||||
'• 필수 필드: title(이벤트 제목), startDate(시작일)',
|
||||
style: TextStyle(fontSize: 14),
|
||||
),
|
||||
const Text(
|
||||
'• 선택 필드: description(설명), location(장소), endDate(종료일), status(상태)',
|
||||
style: TextStyle(fontSize: 14),
|
||||
),
|
||||
const Text(
|
||||
'• 날짜 형식: YYYY-MM-DD 또는 YYYY/MM/DD',
|
||||
style: TextStyle(fontSize: 14),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _pickAndUploadFile,
|
||||
icon: const Icon(Icons.file_upload),
|
||||
label: const Text('파일 선택'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Theme.of(context).primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextButton.icon(
|
||||
onPressed: () async {
|
||||
// 번들된 CSV 템플릿을 읽어 공유
|
||||
final data = await rootBundle.loadString('assets/templates/event_import_template.csv');
|
||||
await SharePlus.instance.share(
|
||||
ShareParams(
|
||||
text: data,
|
||||
subject: 'Event Import Template.csv',
|
||||
),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.download),
|
||||
label: const Text('예시 템플릿 다운로드'),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
TextButton.icon(
|
||||
onPressed: () async {
|
||||
const headers = 'title,startDate,endDate,description,location,status,type,maxParticipants';
|
||||
await Clipboard.setData(const ClipboardData(text: headers));
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('CSV 헤더가 클립보드에 복사되었습니다.')),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.copy_all),
|
||||
label: const Text('CSV 헤더 복사'),
|
||||
),
|
||||
],
|
||||
),
|
||||
title: const Text('이벤트 완전 삭제'),
|
||||
content: Text(
|
||||
'정말 "${event.title}" 이벤트를 완전 삭제하시겠습니까?\n이 작업은 되돌릴 수 없으며 관련된 팀, 점수, 참가자 데이터가 모두 삭제됩니다.',
|
||||
),
|
||||
actions: DialogActions.confirm(
|
||||
onCancel: () => Navigator.of(context).pop(),
|
||||
onConfirm: () => Navigator.of(context).pop(),
|
||||
confirmText: '닫기',
|
||||
onConfirm: () async {
|
||||
try {
|
||||
final eventService = Provider.of<EventService>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
await eventService.deleteEventPermanently(event.id);
|
||||
if (!context.mounted) return;
|
||||
Navigator.of(context).pop();
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('이벤트가 완전 삭제되었습니다')));
|
||||
} catch (e) {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('이벤트 완전 삭제에 실패했습니다: $e')));
|
||||
}
|
||||
},
|
||||
destructive: true,
|
||||
confirmText: '완전 삭제',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 파일 선택 및 업로드
|
||||
Future<void> _pickAndUploadFile() async {
|
||||
// 비동기 전 context 및 파생 객체 캡처 (use_build_context_synchronously 회피)
|
||||
final ctx = context;
|
||||
final navigator = Navigator.of(ctx);
|
||||
final messenger = ScaffoldMessenger.of(ctx);
|
||||
final eventService = Provider.of<EventService>(ctx, listen: false);
|
||||
try {
|
||||
// 파일 피커를 사용하여 엑셀 파일 선택
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['xlsx', 'xls', 'csv'],
|
||||
allowMultiple: false,
|
||||
);
|
||||
// 캘린더 보기 네비게이션
|
||||
void _navigateToCalendarView() {
|
||||
Navigator.of(
|
||||
context,
|
||||
).push(MaterialPageRoute(builder: (_) => const EventCalendarScreen()));
|
||||
}
|
||||
|
||||
if (result == null || result.files.isEmpty) {
|
||||
// 사용자가 파일 선택을 취소함
|
||||
return;
|
||||
}
|
||||
|
||||
final file = result.files.first;
|
||||
final fileName = file.name;
|
||||
final bytes = file.bytes;
|
||||
|
||||
if (bytes == null) {
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('파일을 읽을 수 없습니다. 다른 파일을 선택해주세요.'),
|
||||
duration: Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 로딩 다이얼로그 표시
|
||||
if (!ctx.mounted) return;
|
||||
showDialog(
|
||||
context: ctx,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => AlertDialog(
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const CircularProgressIndicator(),
|
||||
const SizedBox(height: 16),
|
||||
Text('파일 "$fileName" 처리 중...'),
|
||||
const SizedBox(height: 8),
|
||||
const Text('이벤트 데이터를 추출하고 있습니다.'),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// 확장자에 따라 CSV/엑셀 파서 분기 + 안전 처리
|
||||
Map<String, dynamic> parseResult;
|
||||
final lower = fileName.toLowerCase();
|
||||
try {
|
||||
if (lower.endsWith('.csv')) {
|
||||
parseResult = EventCsvParser.parseCsvBytes(bytes);
|
||||
} else {
|
||||
parseResult = EventExcelParser.parseExcelBytes(bytes);
|
||||
}
|
||||
} catch (e) {
|
||||
navigator.pop();
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('파일 파싱 중 오류가 발생했습니다: ${e.toString()}\n가능하면 CSV 또는 XLSX 형식을 사용해 주세요.'),
|
||||
duration: const Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
final processedRows = parseResult['processedRows'] as int;
|
||||
final events = parseResult['validEvents'] as List<Map<String, dynamic>>;
|
||||
final invalidRows = parseResult['invalidRows'] as int;
|
||||
final invalidRowIndices = (parseResult['invalidRowIndices'] as List<dynamic>? ?? const [])
|
||||
.map((e) => e.toString())
|
||||
.toList();
|
||||
final Map<String, String> invalidRowReasons = ((parseResult['invalidRowReasons'] as Map?) ?? const {})
|
||||
.map((key, value) => MapEntry(key.toString(), value?.toString() ?? ''));
|
||||
final error = parseResult['error'] as String?;
|
||||
|
||||
// 오류가 있거나 이벤트가 없는 경우
|
||||
if (error != null) {
|
||||
// 로딩 다이얼로그 닫기 및 에러 스낵바 표시
|
||||
navigator.pop();
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(error),
|
||||
duration: const Duration(seconds: 4),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 로딩 다이얼로그 업데이트
|
||||
if (!ctx.mounted) {
|
||||
if (navigator.canPop()) navigator.pop();
|
||||
return;
|
||||
}
|
||||
navigator.pop();
|
||||
if (!ctx.mounted) return;
|
||||
showDialog(
|
||||
context: ctx,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => AlertDialog(
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const CircularProgressIndicator(),
|
||||
const SizedBox(height: 16),
|
||||
Text('이벤트 ${events.length}개 생성 중...'),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// 이벤트 서비스를 통해 이벤트 생성 (비동기 이전에 캡처해 둔 것을 사용)
|
||||
final results = await eventService.createEventsFromFile(events);
|
||||
|
||||
// 로딩 다이얼로그 닫기
|
||||
if (navigator.canPop()) navigator.pop();
|
||||
|
||||
// 결과 표시
|
||||
if (!ctx.mounted) return;
|
||||
// 상세 결과 다이얼로그 표시
|
||||
if (!ctx.mounted) return;
|
||||
showDialog(
|
||||
context: ctx,
|
||||
builder: (dialogContext) => ImportResultDialog(
|
||||
fileName: fileName,
|
||||
processedRows: processedRows,
|
||||
createdCount: results.length,
|
||||
invalidRows: invalidRows,
|
||||
invalidRowIndices: invalidRowIndices,
|
||||
invalidRowReasons: invalidRowReasons,
|
||||
onClose: () {
|
||||
Navigator.of(dialogContext).pop();
|
||||
// 파일에서 생성 → 가져오기 마법사로 이동
|
||||
void _showFileUploadDialog() {
|
||||
Navigator.of(context)
|
||||
.push(MaterialPageRoute(builder: (_) => const EventImportWizard()))
|
||||
.then((result) {
|
||||
if (result != null) {
|
||||
_loadEvents();
|
||||
},
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
// 로딩 다이얼로그가 열려 있으면 닫기
|
||||
if (navigator.canPop()) navigator.pop();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 사용자 친화적인 오류 메시지 표시 (messenger는 사전 캡처되어 context 불필요)
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('파일 처리 중 오류가 발생했습니다: $e'),
|
||||
duration: const Duration(seconds: 4),
|
||||
),
|
||||
);
|
||||
}
|
||||
// 호환성: 레거시 참조용 별칭 (내부적으로 마법사 호출)
|
||||
void _pickAndUploadFile() {
|
||||
_showFileUploadDialog();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +84,16 @@ class _MembersScreenState extends State<MembersScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('회원'),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: '회원 추가',
|
||||
icon: const Icon(Icons.person_add),
|
||||
onPressed: _showAddMemberDialog,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// 검색 바
|
||||
@@ -154,14 +164,7 @@ class _MembersScreenState extends State<MembersScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () {
|
||||
// 회원 추가 화면으로 이동 (추후 구현)
|
||||
_showAddMemberDialog();
|
||||
},
|
||||
backgroundColor: Colors.blue,
|
||||
child: const Icon(Icons.add, color: Colors.white),
|
||||
),
|
||||
// FAB 제거: AppBar actions로 이동
|
||||
);
|
||||
}
|
||||
|
||||
@@ -223,6 +226,24 @@ class _MembersScreenState extends State<MembersScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
// 평균
|
||||
if (member.average != null)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(left: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.indigo.shade100,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
'AVG ${member.average!.toStringAsFixed(1)}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.indigo.shade800,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (member.email.isNotEmpty || member.phone != null)
|
||||
@@ -249,26 +270,31 @@ class _MembersScreenState extends State<MembersScreen> {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
// 2열: 모든 뱃지
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 4,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
// 회원 유형 배지 (공통 스타일 적용)
|
||||
// 회원 유형 배지
|
||||
BadgeStyles.memberType(memberTypeText),
|
||||
// 가입일 표시
|
||||
if (member.joinDate != null)
|
||||
Text(
|
||||
'가입: ${formatDate(member.joinDate!)}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
// 상태 표시 (공통 배지 적용)
|
||||
// 상태 배지
|
||||
BadgeStyles.memberStatus(getStatusLabel(member.status)),
|
||||
],
|
||||
),
|
||||
// 3열: 가입일
|
||||
if (member.joinDate != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'가입: ${formatDate(member.joinDate!)}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -11,12 +11,14 @@ class ScoreFormScreen extends StatefulWidget {
|
||||
final String eventId;
|
||||
final Score? score; // null이면 새 점수 추가, 아니면 점수 수정
|
||||
final List<Participant> participants;
|
||||
final int gameCount; // 이벤트의 게임 수 (추가 모드에서 사용)
|
||||
|
||||
const ScoreFormScreen({
|
||||
super.key,
|
||||
required this.eventId,
|
||||
this.score,
|
||||
required this.participants,
|
||||
this.gameCount = 1,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -26,15 +28,12 @@ class ScoreFormScreen extends StatefulWidget {
|
||||
class _ScoreFormScreenState extends State<ScoreFormScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
bool _isLoading = false;
|
||||
bool _useFrameInput = false; // 프레임별 입력 사용 여부
|
||||
|
||||
// 폼 필드 컨트롤러
|
||||
final _notesController = TextEditingController();
|
||||
final _totalScoreController = TextEditingController(); // 총점 컨트롤러 추가
|
||||
final List<TextEditingController> _frameControllers = List.generate(
|
||||
10,
|
||||
(_) => TextEditingController(),
|
||||
);
|
||||
List<TextEditingController> _gameScoreControllers = [];
|
||||
List<TextEditingController> _gameHandicapControllers = [];
|
||||
|
||||
// 점수 데이터
|
||||
String? _selectedParticipantId;
|
||||
@@ -49,7 +48,17 @@ class _ScoreFormScreenState extends State<ScoreFormScreen> {
|
||||
// 수정 모드인 경우 기존 데이터 로드
|
||||
if (widget.score != null) {
|
||||
_notesController.text = widget.score!.notes ?? '';
|
||||
_selectedParticipantId = widget.score!.memberId;
|
||||
// 편집 모드: 기존 점수의 participantId를 우선 사용, 없으면 memberId와 매칭되는 참가자 id를 탐색
|
||||
_selectedParticipantId = widget.score!.participantId;
|
||||
if (_selectedParticipantId == null || _selectedParticipantId!.isEmpty) {
|
||||
final match = widget.participants.firstWhere(
|
||||
(p) => p.memberId == widget.score!.memberId,
|
||||
orElse: () => widget.participants.isNotEmpty
|
||||
? widget.participants.first
|
||||
: Participant(id: '', eventId: widget.eventId, memberId: '', name: '알 수 없음'),
|
||||
);
|
||||
_selectedParticipantId = match.id;
|
||||
}
|
||||
_scoreDate = widget.score!.date;
|
||||
_totalScoreController.text = widget.score!.totalScore.toString();
|
||||
_totalScore = widget.score!.totalScore;
|
||||
@@ -58,21 +67,24 @@ class _ScoreFormScreenState extends State<ScoreFormScreen> {
|
||||
if (widget.score!.handicap != null) {
|
||||
_handicapController.text = widget.score!.handicap.toString();
|
||||
}
|
||||
|
||||
// 프레임별 점수가 있는 경우 프레임별 입력 모드로 설정
|
||||
if (widget.score!.frames.isNotEmpty) {
|
||||
setState(() {
|
||||
_useFrameInput = true;
|
||||
});
|
||||
|
||||
// 프레임별 점수 설정
|
||||
for (int i = 0; i < widget.score!.frames.length && i < 10; i++) {
|
||||
_frameControllers[i].text = widget.score!.frames[i].toString();
|
||||
}
|
||||
// 편집 모드에서는 게임별 입력을 사용하지 않음(단일 점수 수정)
|
||||
_gameScoreControllers = const [];
|
||||
_gameHandicapControllers = const [];
|
||||
} else {
|
||||
// 추가 모드: 참가자 유무와 무관하게 gameCount를 기준으로 컨트롤러 생성
|
||||
if (widget.participants.isNotEmpty) {
|
||||
// 새 점수 추가 시 기본 선택: participant.id 사용
|
||||
_selectedParticipantId = widget.participants.first.id;
|
||||
}
|
||||
} else if (widget.participants.isNotEmpty) {
|
||||
// 새 점수 추가 시 첫 번째 참가자 선택
|
||||
_selectedParticipantId = widget.participants.first.memberId;
|
||||
final count = widget.gameCount > 0 ? widget.gameCount : 1;
|
||||
_gameScoreControllers = List.generate(
|
||||
count,
|
||||
(_) => TextEditingController(),
|
||||
);
|
||||
_gameHandicapControllers = List.generate(
|
||||
count,
|
||||
(_) => TextEditingController(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,31 +93,20 @@ class _ScoreFormScreenState extends State<ScoreFormScreen> {
|
||||
_notesController.dispose();
|
||||
_handicapController.dispose();
|
||||
_totalScoreController.dispose();
|
||||
for (var controller in _frameControllers) {
|
||||
for (var controller in _gameScoreControllers) {
|
||||
controller.dispose();
|
||||
}
|
||||
for (var controller in _gameHandicapControllers) {
|
||||
controller.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// 총점 계산 (프레임별 입력 모드에서만 사용)
|
||||
// 총점 계산은 편집 모드에서만 필요(단일 필드)
|
||||
void _calculateTotalScore() {
|
||||
if (_useFrameInput) {
|
||||
int total = 0;
|
||||
for (var controller in _frameControllers) {
|
||||
if (controller.text.isNotEmpty) {
|
||||
total += int.tryParse(controller.text) ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_totalScore = total;
|
||||
_totalScoreController.text = total.toString();
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
_totalScore = int.tryParse(_totalScoreController.text) ?? 0;
|
||||
});
|
||||
}
|
||||
setState(() {
|
||||
_totalScore = int.tryParse(_totalScoreController.text) ?? 0;
|
||||
});
|
||||
}
|
||||
|
||||
// 점수 저장
|
||||
@@ -121,50 +122,51 @@ class _ScoreFormScreenState extends State<ScoreFormScreen> {
|
||||
try {
|
||||
final eventService = Provider.of<EventService>(context, listen: false);
|
||||
|
||||
// 점수 데이터 준비
|
||||
final Map<String, dynamic> scoreData = {
|
||||
// 공통 메타 (add는 participantId/score/gameNumber만 필수)
|
||||
final common = {
|
||||
'eventId': widget.eventId,
|
||||
'memberId': _selectedParticipantId,
|
||||
'date': _scoreDate.toIso8601String(),
|
||||
'handicap': _handicapController.text.isEmpty
|
||||
? null
|
||||
: int.parse(_handicapController.text),
|
||||
'notes': _notesController.text.trim().isEmpty
|
||||
? null
|
||||
: _notesController.text.trim(),
|
||||
// notes/date는 백엔드 add에서 사용하지 않으므로 제외
|
||||
};
|
||||
|
||||
// 프레임별 입력 모드인 경우
|
||||
if (_useFrameInput) {
|
||||
final frames = _frameControllers
|
||||
.map(
|
||||
(controller) =>
|
||||
controller.text.isEmpty ? 0 : int.parse(controller.text),
|
||||
)
|
||||
.toList();
|
||||
scoreData['frames'] = frames;
|
||||
scoreData['totalScore'] = _totalScore;
|
||||
} else {
|
||||
// 총점 입력 모드인 경우
|
||||
scoreData['totalScore'] = int.tryParse(_totalScoreController.text) ?? 0;
|
||||
scoreData['frames'] = []; // 빈 프레임 배열 전송
|
||||
}
|
||||
|
||||
if (widget.score == null) {
|
||||
// 새 점수 추가
|
||||
await eventService.addScore(widget.eventId, scoreData);
|
||||
// 새 점수 추가: 게임 수 만큼 배치 생성 (게임별 핸디캡 포함)
|
||||
for (int i = 0; i < _gameScoreControllers.length; i++) {
|
||||
final scoreText = _gameScoreControllers[i].text.trim();
|
||||
final handicapText = _gameHandicapControllers[i].text.trim();
|
||||
if (scoreText.isEmpty && handicapText.isEmpty) {
|
||||
continue; // 완전 비어있으면 건너뜀
|
||||
}
|
||||
final gameScore = int.tryParse(scoreText) ?? 0;
|
||||
final gameHandicap = int.tryParse(handicapText) ?? 0;
|
||||
final payload = {
|
||||
...common,
|
||||
'participantId': _selectedParticipantId,
|
||||
'gameNumber': i + 1,
|
||||
'score': gameScore,
|
||||
'handicap': gameHandicap,
|
||||
};
|
||||
await eventService.addScore(widget.eventId, payload);
|
||||
}
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('점수가 추가되었습니다')));
|
||||
}
|
||||
} else {
|
||||
// 기존 점수 수정
|
||||
await eventService.updateScore(
|
||||
widget.eventId,
|
||||
widget.score!.id,
|
||||
scoreData,
|
||||
);
|
||||
// 기존 점수 수정: 서버 요구 필드와 날짜 보존
|
||||
final total = int.tryParse(_totalScoreController.text) ?? 0;
|
||||
final payload = {
|
||||
...common,
|
||||
if (_selectedParticipantId != null) 'participantId': _selectedParticipantId,
|
||||
'gameNumber': widget.score!.gameNumber ?? 1,
|
||||
'score': total,
|
||||
'totalScore': total, // 호환 목적
|
||||
'handicap': _handicapController.text.isEmpty
|
||||
? null
|
||||
: int.parse(_handicapController.text),
|
||||
'date': _scoreDate.toIso8601String(),
|
||||
};
|
||||
await eventService.updateScore(widget.eventId, widget.score!.id, payload);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
@@ -217,7 +219,7 @@ class _ScoreFormScreenState extends State<ScoreFormScreen> {
|
||||
isExpanded: true,
|
||||
items: widget.participants.map((participant) {
|
||||
return DropdownMenuItem(
|
||||
value: participant.memberId,
|
||||
value: participant.id,
|
||||
child: Text(
|
||||
participant.name ?? '이름 없음',
|
||||
maxLines: 1,
|
||||
@@ -266,154 +268,158 @@ class _ScoreFormScreenState extends State<ScoreFormScreen> {
|
||||
// 점수 입력 섹션
|
||||
_buildSectionTitle('점수 입력'),
|
||||
|
||||
// 점수 입력 모드 전환 스위치
|
||||
SwitchListTile(
|
||||
title: const Text('프레임별 점수 입력'),
|
||||
subtitle: const Text('각 프레임마다 점수를 개별적으로 입력합니다'),
|
||||
value: _useFrameInput,
|
||||
activeColor: Theme.of(context).primaryColor,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_useFrameInput = value;
|
||||
if (!value) {
|
||||
// 총점 입력 모드로 전환 시 현재 계산된 총점 유지
|
||||
_totalScoreController.text = _totalScore.toString();
|
||||
} else {
|
||||
// 프레임별 입력 모드로 전환 시 총점 계산
|
||||
_calculateTotalScore();
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 입력 모드에 따라 다른 UI 표시
|
||||
_useFrameInput
|
||||
? Column(
|
||||
children: [
|
||||
// 프레임별 점수 입력
|
||||
GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 5,
|
||||
childAspectRatio: 1.5,
|
||||
crossAxisSpacing: 10,
|
||||
mainAxisSpacing: 10,
|
||||
),
|
||||
itemCount: 10,
|
||||
itemBuilder: (context, index) {
|
||||
return _buildFrameInput(index);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 계산된 총점 표시
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.shade50,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment:
|
||||
MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'계산된 총점:',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_totalScore.toString(),
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.blue,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: TextFormField(
|
||||
// 총점 직접 입력
|
||||
controller: _totalScoreController,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '총점 *',
|
||||
hintText: '게임 총점을 입력하세요',
|
||||
prefixIcon: Icon(Icons.score),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '총점을 입력해주세요';
|
||||
}
|
||||
final score = int.tryParse(value);
|
||||
if (score == null) {
|
||||
return '숫자만 입력해주세요';
|
||||
}
|
||||
if (score < 0 || score > 300) {
|
||||
return '0-300 사이의 값을 입력해주세요';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_totalScore = int.tryParse(value) ?? 0;
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 핸디캡 입력
|
||||
TextFormField(
|
||||
controller: _handicapController,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '핸디캡',
|
||||
hintText: '핸디캡 점수를 입력하세요',
|
||||
prefixIcon: Icon(Icons.add_circle_outline),
|
||||
// 추가 모드: 게임 수만큼 (점수 + 핸디캡 + 합계) 행, 수정 모드: 단일 점수/핸디캡
|
||||
if (widget.score == null) ...[
|
||||
// 런타임에서도 리스트 길이 보정 (hot reload/동적 변경 대비)
|
||||
_ensureGameControllersLength(
|
||||
widget.gameCount > 0 ? widget.gameCount : 1,
|
||||
),
|
||||
validator: (value) {
|
||||
if (value != null && value.isNotEmpty) {
|
||||
final handicap = int.tryParse(value);
|
||||
if (handicap == null) {
|
||||
return '숫자만 입력해주세요';
|
||||
}
|
||||
if (handicap < 0 || handicap > 200) {
|
||||
return '0-200 사이의 값을 입력해주세요';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
// 핸디캡 포함 총점 표시
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
Column(
|
||||
children: [
|
||||
const Text(
|
||||
'핸디캡 포함 총점: ',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
Text(
|
||||
'${_totalScore + (int.tryParse(_handicapController.text) ?? 0)}',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.green,
|
||||
for (int i = 0; i < _gameScoreControllers.length; i++)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12.0),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _gameScoreControllers[i],
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: InputDecoration(
|
||||
labelText: '${i + 1}G 점수',
|
||||
hintText: '0-300',
|
||||
prefixIcon: const Icon(Icons.score),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value != null && value.isNotEmpty) {
|
||||
final score = int.tryParse(value);
|
||||
if (score == null) {
|
||||
return '숫자만 입력';
|
||||
}
|
||||
if (score < 0 || score > 300) {
|
||||
return '0-300';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _gameHandicapControllers[i],
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '핸디캡',
|
||||
prefixIcon: Icon(
|
||||
Icons.add_circle_outline,
|
||||
),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value != null && value.isNotEmpty) {
|
||||
final handicap = int.tryParse(value);
|
||||
if (handicap == null) {
|
||||
return '숫자만 입력';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_PerRowSum(
|
||||
scoreText:
|
||||
(i < _gameScoreControllers.length)
|
||||
? _gameScoreControllers[i].text
|
||||
: '',
|
||||
handicapText:
|
||||
(i < _gameHandicapControllers.length)
|
||||
? _gameHandicapControllers[i].text
|
||||
: '',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 전체 합계 표시
|
||||
const SizedBox(height: 8),
|
||||
_TotalsSummary(
|
||||
totalScore: _sumGameScores(),
|
||||
totalWithHandicap: _sumGameScoresWithHandicap(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
] else
|
||||
TextFormField(
|
||||
controller: _totalScoreController,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '총점 *',
|
||||
hintText: '게임 총점을 입력하세요',
|
||||
prefixIcon: Icon(Icons.score),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '총점을 입력해주세요';
|
||||
}
|
||||
final score = int.tryParse(value);
|
||||
if (score == null) {
|
||||
return '숫자만 입력해주세요';
|
||||
}
|
||||
if (score < 0 || score > 300) {
|
||||
return '0-300 사이의 값을 입력해주세요';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
onChanged: (_) => _calculateTotalScore(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 수정 모드에서만 단일 핸디캡과 합계 표시
|
||||
if (widget.score != null) ...[
|
||||
TextFormField(
|
||||
controller: _handicapController,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '핸디캡',
|
||||
hintText: '핸디캡 점수를 입력하세요',
|
||||
prefixIcon: Icon(Icons.add_circle_outline),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value != null && value.isNotEmpty) {
|
||||
final handicap = int.tryParse(value);
|
||||
if (handicap == null) {
|
||||
return '숫자만 입력해주세요';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
const Text(
|
||||
'핸디캡 포함 총점: ',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
Text(
|
||||
'${_totalScore + (int.tryParse(_handicapController.text) ?? 0)}',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.green,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 메모 섹션
|
||||
@@ -451,43 +457,7 @@ class _ScoreFormScreenState extends State<ScoreFormScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
// 프레임 입력 위젯
|
||||
Widget _buildFrameInput(int frameIndex) {
|
||||
return Column(
|
||||
children: [
|
||||
Text(
|
||||
'${frameIndex + 1}',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _frameControllers[frameIndex],
|
||||
keyboardType: TextInputType.number,
|
||||
textAlign: TextAlign.center,
|
||||
decoration: InputDecoration(
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value != null && value.isNotEmpty) {
|
||||
final score = int.tryParse(value);
|
||||
if (score == null) {
|
||||
return '숫자만';
|
||||
}
|
||||
if (score < 0 || score > 300) {
|
||||
return '0-300';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
onChanged: (_) => _calculateTotalScore(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
// 프레임 입력 UI 제거됨
|
||||
|
||||
// 섹션 제목 위젯
|
||||
Widget _buildSectionTitle(String title) {
|
||||
@@ -503,4 +473,125 @@ class _ScoreFormScreenState extends State<ScoreFormScreen> {
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// 컨트롤러 리스트 길이 보정 (gameCount 변화나 hot reload 대비)
|
||||
Widget _ensureGameControllersLength(int desired) {
|
||||
if (desired < 1) desired = 1;
|
||||
// 점수 컨트롤러 보정
|
||||
while (_gameScoreControllers.length < desired) {
|
||||
_gameScoreControllers.add(TextEditingController());
|
||||
}
|
||||
if (_gameScoreControllers.length > desired) {
|
||||
_gameScoreControllers = _gameScoreControllers.sublist(0, desired);
|
||||
}
|
||||
// 핸디캡 컨트롤러 보정
|
||||
while (_gameHandicapControllers.length < desired) {
|
||||
_gameHandicapControllers.add(TextEditingController());
|
||||
}
|
||||
if (_gameHandicapControllers.length > desired) {
|
||||
_gameHandicapControllers = _gameHandicapControllers.sublist(0, desired);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
// 합계 계산: 점수 합
|
||||
int _sumGameScores() {
|
||||
int sum = 0;
|
||||
for (final c in _gameScoreControllers) {
|
||||
final v = int.tryParse(c.text.trim());
|
||||
if (v != null) sum += v;
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
|
||||
// 합계 계산: 점수+핸디캡 합
|
||||
int _sumGameScoresWithHandicap() {
|
||||
int sum = 0;
|
||||
for (int i = 0; i < _gameScoreControllers.length; i++) {
|
||||
final s = int.tryParse(_gameScoreControllers[i].text.trim()) ?? 0;
|
||||
final hText = (i < _gameHandicapControllers.length)
|
||||
? _gameHandicapControllers[i].text.trim()
|
||||
: '';
|
||||
final h = int.tryParse(hText) ?? 0;
|
||||
sum += s + h;
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
}
|
||||
|
||||
// 행 우측 합계 표시 위젯
|
||||
class _PerRowSum extends StatelessWidget {
|
||||
final String scoreText;
|
||||
final String handicapText;
|
||||
const _PerRowSum({required this.scoreText, required this.handicapText});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final score = int.tryParse(scoreText.trim()) ?? 0;
|
||||
final handicap = int.tryParse(handicapText.trim()) ?? 0;
|
||||
final total = score + handicap;
|
||||
return SizedBox(
|
||||
width: 64,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
const Text('합계'),
|
||||
Text(
|
||||
'$total',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.blue,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 전체 합계 표시 위젯
|
||||
class _TotalsSummary extends StatelessWidget {
|
||||
final int totalScore;
|
||||
final int totalWithHandicap;
|
||||
const _TotalsSummary({
|
||||
required this.totalScore,
|
||||
required this.totalWithHandicap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('총점: '),
|
||||
Text(
|
||||
'$totalScore',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('핸디캡 포함 총점: '),
|
||||
Text(
|
||||
'$totalWithHandicap',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.green,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -0,0 +1,234 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../models/score_model.dart';
|
||||
import '../../models/participant_model.dart';
|
||||
import '../../services/event_service.dart';
|
||||
|
||||
class ScoreBatchEditScreen extends StatefulWidget {
|
||||
final String eventId;
|
||||
final Participant participant;
|
||||
final List<Score> scores; // 해당 참가자의 기존 점수들 (게임번호 기준 정렬 권장)
|
||||
final int gameCount; // 이벤트 전체 게임 수
|
||||
|
||||
const ScoreBatchEditScreen({
|
||||
super.key,
|
||||
required this.eventId,
|
||||
required this.participant,
|
||||
required this.scores,
|
||||
required this.gameCount,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ScoreBatchEditScreen> createState() => _ScoreBatchEditScreenState();
|
||||
}
|
||||
|
||||
class _ScoreBatchEditScreenState extends State<ScoreBatchEditScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
bool _isSaving = false;
|
||||
late List<TextEditingController> _scoreCtrls;
|
||||
late List<TextEditingController> _handicapCtrls;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 기존 점수 맵: gameNumber -> Score
|
||||
final byGame = <int, Score>{};
|
||||
for (final s in widget.scores) {
|
||||
final gn = s.gameNumber ?? 0;
|
||||
if (gn > 0) byGame[gn] = s;
|
||||
}
|
||||
|
||||
final count = widget.gameCount > 0 ? widget.gameCount : (byGame.keys.isEmpty ? 1 : byGame.keys.reduce((a,b)=>a>b?a:b));
|
||||
_scoreCtrls = List.generate(count, (i) {
|
||||
final gn = i + 1;
|
||||
final s = byGame[gn];
|
||||
return TextEditingController(text: s != null ? s.totalScore.toString() : '');
|
||||
});
|
||||
_handicapCtrls = List.generate(count, (i) {
|
||||
final gn = i + 1;
|
||||
final s = byGame[gn];
|
||||
return TextEditingController(text: s != null ? (s.handicap ?? 0).toString() : '');
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final c in _scoreCtrls) {
|
||||
c.dispose();
|
||||
}
|
||||
for (final c in _handicapCtrls) {
|
||||
c.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _saveAll() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
setState(() => _isSaving = true);
|
||||
try {
|
||||
final eventService = Provider.of<EventService>(context, listen: false);
|
||||
// 기존 점수 맵 구성
|
||||
final byGame = <int, Score>{};
|
||||
for (final s in widget.scores) {
|
||||
final gn = s.gameNumber ?? 0;
|
||||
if (gn > 0) byGame[gn] = s;
|
||||
}
|
||||
final count = _scoreCtrls.length;
|
||||
for (int i = 0; i < count; i++) {
|
||||
final gameNo = i + 1;
|
||||
final scoreText = _scoreCtrls[i].text.trim();
|
||||
final hText = _handicapCtrls[i].text.trim();
|
||||
final hasAny = scoreText.isNotEmpty || hText.isNotEmpty;
|
||||
if (!hasAny) continue; // 완전 비어있으면 건너뜀
|
||||
|
||||
final newScore = int.tryParse(scoreText) ?? 0;
|
||||
final newHdc = int.tryParse(hText.isEmpty ? '0' : hText) ?? 0;
|
||||
final existing = byGame[gameNo];
|
||||
if (existing != null) {
|
||||
// 업데이트: 날짜 보존
|
||||
await eventService.updateScore(widget.eventId, existing.id, {
|
||||
'gameNumber': gameNo,
|
||||
'score': newScore,
|
||||
'totalScore': newScore, // 호환 목적
|
||||
'handicap': newHdc,
|
||||
'date': existing.date.toIso8601String(),
|
||||
});
|
||||
} else {
|
||||
// 신규 추가
|
||||
await eventService.addScore(widget.eventId, {
|
||||
'eventId': widget.eventId,
|
||||
'participantId': widget.participant.id,
|
||||
'gameNumber': gameNo,
|
||||
'score': newScore,
|
||||
'handicap': newHdc,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (mounted) Navigator.of(context).pop(true);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _isSaving = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('일괄 수정 실패: $e')));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final count = _scoreCtrls.length;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('${widget.participant.name ?? '참가자'} - 점수 일괄 수정'),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: _isSaving ? null : _saveAll,
|
||||
icon: const Icon(Icons.save),
|
||||
tooltip: '저장',
|
||||
)
|
||||
],
|
||||
),
|
||||
body: _isSaving
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: Form(
|
||||
key: _formKey,
|
||||
child: ListView.separated(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: count,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 12),
|
||||
itemBuilder: (context, i) {
|
||||
// 기존 점수 찾기
|
||||
final gn = i + 1;
|
||||
final s = widget.scores.firstWhere(
|
||||
(e) => (e.gameNumber ?? 0) == gn,
|
||||
orElse: () => Score(
|
||||
id: '',
|
||||
memberId: widget.participant.memberId,
|
||||
eventId: widget.eventId,
|
||||
clubId: '',
|
||||
frames: const [],
|
||||
totalScore: 0,
|
||||
handicap: 0,
|
||||
gameNumber: gn,
|
||||
date: DateTime.now(),
|
||||
),
|
||||
);
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 14,
|
||||
child: Text(gn.toString()),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text('기록일: ${DateFormat('yyyy-MM-dd').format(s.date)}'),
|
||||
const Spacer(),
|
||||
Text('합계: ${(_scoreCtrls[i].text.isEmpty ? 0 : int.tryParse(_scoreCtrls[i].text) ?? 0) + (int.tryParse(_handicapCtrls[i].text.isEmpty ? '0' : _handicapCtrls[i].text) ?? 0)}'),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _scoreCtrls[i],
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '점수 (0-300)',
|
||||
prefixIcon: Icon(Icons.score),
|
||||
),
|
||||
validator: (v) {
|
||||
if (v == null || v.isEmpty) return null; // 신규 입력은 비워둘 수 있음
|
||||
final n = int.tryParse(v);
|
||||
if (n == null) return '숫자만';
|
||||
if (n < 0 || n > 300) return '0-300';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _handicapCtrls[i],
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '핸디캡',
|
||||
prefixIcon: Icon(Icons.add_circle_outline),
|
||||
),
|
||||
validator: (v) {
|
||||
if (v == null || v.isEmpty) return null;
|
||||
final n = int.tryParse(v);
|
||||
if (n == null) return '숫자만';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: ElevatedButton.icon(
|
||||
icon: const Icon(Icons.save),
|
||||
label: const Text('모든 게임 저장'),
|
||||
onPressed: _isSaving ? null : _saveAll,
|
||||
style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 14)),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -64,9 +64,11 @@ class ApiService {
|
||||
// POST 요청
|
||||
Future<dynamic> post(String path, {dynamic data}) async {
|
||||
try {
|
||||
final isForm = data is FormData;
|
||||
final response = await _dio.post(
|
||||
path,
|
||||
data: data ?? {},
|
||||
options: isForm ? Options(contentType: 'multipart/form-data') : null,
|
||||
);
|
||||
return response.data;
|
||||
} on DioException catch (e) {
|
||||
@@ -99,14 +101,35 @@ class ApiService {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE 요청 (body 데이터 포함이 필요한 경우 사용)
|
||||
Future<dynamic> deleteWithData(String path, {dynamic data}) async {
|
||||
try {
|
||||
final response = await _dio.delete(path, data: data);
|
||||
return response.data;
|
||||
} on DioException catch (e) {
|
||||
_handleError(e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// 에러 처리
|
||||
void _handleError(DioException e) {
|
||||
if (e.response != null) {
|
||||
print('API 에러: ${e.response?.statusCode} - ${e.response?.data}');
|
||||
|
||||
final status = e.response?.statusCode;
|
||||
final dataStr = e.response?.data?.toString() ?? '';
|
||||
|
||||
// 초기 진입 등에서 발생 가능한 EXPECTED 400은 콘솔에 노이즈로 찍지 않음
|
||||
// 예: { error: '클럽 정보가 필요합니다.' }
|
||||
if (status == 400 && (dataStr.contains('클럽 정보가 필요합니다') || dataStr.contains('club'))) {
|
||||
// 무음 처리: 상위 호출부에서 사용자 경험을 고려해 처리함
|
||||
return;
|
||||
}
|
||||
|
||||
print('API 에러: $status - ${e.response?.data}');
|
||||
|
||||
// 401 오류 (인증 실패) 처리
|
||||
if (e.response?.statusCode == 401) {
|
||||
if (status == 401) {
|
||||
print('인증 오류: 토큰이 유효하지 않습니다. 로그아웃 처리합니다.');
|
||||
_handleUnauthorized();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../config/api_config.dart';
|
||||
@@ -38,6 +41,38 @@ class EventService with ChangeNotifier {
|
||||
_apiService.setToken(token);
|
||||
}
|
||||
|
||||
// [팀 생성 기능] 서버 포맷으로 팀 배정 저장
|
||||
// serverTeams 형태 예시:
|
||||
// [
|
||||
// { 'teamNumber': 1, 'handicap': 0, 'members': [ { 'id': <eventParticipantId>, 'order': 1 }, ... ] },
|
||||
// ...
|
||||
// ]
|
||||
Future<bool> createOrUpdateEventTeams(String eventId, List<Map<String, dynamic>> serverTeams) async {
|
||||
if (_token == null) {
|
||||
throw Exception('인증 정보가 필요합니다');
|
||||
}
|
||||
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
await _apiService.post(
|
||||
'${ApiConfig.clubs}/events/$eventId/teams',
|
||||
data: {
|
||||
'clubId': _clubId,
|
||||
'teams': serverTeams,
|
||||
},
|
||||
);
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
return true;
|
||||
} catch (e) {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
throw Exception('팀 배정 저장에 실패했습니다: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// 클럽 ID 설정
|
||||
void setClubId(String clubId) {
|
||||
_clubId = clubId;
|
||||
@@ -95,12 +130,19 @@ class EventService with ChangeNotifier {
|
||||
}
|
||||
|
||||
try {
|
||||
final data = await _apiService.post(
|
||||
'${ApiConfig.clubs}/events/detail',
|
||||
data: {'eventId': eventId},
|
||||
// 백엔드 라우트: GET /api/club/events/:id
|
||||
final data = await _apiService.get(
|
||||
'${ApiConfig.clubs}/events/$eventId',
|
||||
queryParameters: {'clubId': _clubId},
|
||||
);
|
||||
|
||||
return Event.fromJson(data['event']);
|
||||
// 서버가 { event: {...} } 또는 이벤트 객체 자체를 반환할 수 있음
|
||||
final dynamic body = data;
|
||||
final Map<String, dynamic> json =
|
||||
body is Map<String, dynamic>
|
||||
? (body.containsKey('event') ? Map<String, dynamic>.from(body['event']) : body)
|
||||
: <String, dynamic>{};
|
||||
return Event.fromJson(json);
|
||||
} catch (e) {
|
||||
throw Exception('이벤트 정보를 불러오는데 실패했습니다: $e');
|
||||
}
|
||||
@@ -141,17 +183,41 @@ class EventService with ChangeNotifier {
|
||||
String eventId,
|
||||
Map<String, dynamic> updates,
|
||||
) async {
|
||||
if (_token == null || _clubId == null) {
|
||||
throw Exception('인증 또는 클럽 정보가 필요합니다');
|
||||
if (_token == null) {
|
||||
throw Exception('인증 정보가 필요합니다');
|
||||
}
|
||||
|
||||
// 업데이트 전에 clubId가 비어 있으면 SharedPreferences에서 보강 시도
|
||||
if (_clubId == null) {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final cid = prefs.getString(ApiConfig.clubIdKey);
|
||||
if (cid != null) {
|
||||
_clubId = cid;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
if (_clubId == null) {
|
||||
throw Exception('클럽 정보가 아직 설정되지 않았습니다. 클럽 선택 후 다시 시도해 주세요.');
|
||||
}
|
||||
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
// 빈 문자열을 명시적 null로 변환하여 서버에서 정확히 해석되도록 함
|
||||
final sanitized = Map<String, dynamic>.from(updates)..updateAll((key, value) {
|
||||
if (value is String && value.trim().isEmpty) return null;
|
||||
return value;
|
||||
});
|
||||
|
||||
final data = await _apiService.put(
|
||||
'${ApiConfig.clubs}/events/$eventId',
|
||||
data: updates,
|
||||
data: {
|
||||
'id': eventId,
|
||||
'clubId': _clubId,
|
||||
...sanitized,
|
||||
},
|
||||
);
|
||||
|
||||
final updatedEvent = Event.fromJson(data['event']);
|
||||
@@ -183,7 +249,10 @@ class EventService with ChangeNotifier {
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
await _apiService.delete('${ApiConfig.clubs}/events/$eventId');
|
||||
await _apiService.deleteWithData(
|
||||
'${ApiConfig.clubs}/events/$eventId',
|
||||
data: { 'clubId': _clubId },
|
||||
);
|
||||
|
||||
// 이벤트 목록에서 삭제
|
||||
_events.removeWhere((event) => event.id == eventId);
|
||||
@@ -199,6 +268,73 @@ class EventService with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
// 이벤트 완전 삭제 (하드 삭제)
|
||||
Future<bool> deleteEventPermanently(String eventId) async {
|
||||
if (_token == null || _clubId == null) {
|
||||
throw Exception('인증 또는 클럽 정보가 필요합니다');
|
||||
}
|
||||
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
await _apiService.deleteWithData(
|
||||
'${ApiConfig.clubs}/events/$eventId/permanent',
|
||||
data: { 'clubId': _clubId },
|
||||
);
|
||||
|
||||
// 이벤트 목록에서 제거
|
||||
_events.removeWhere((event) => event.id == eventId);
|
||||
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
return true;
|
||||
} catch (e) {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
throw Exception('이벤트 완전 삭제에 실패했습니다: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// 임포트 저장 롤백
|
||||
Future<bool> rollbackEventImport({
|
||||
required String eventId,
|
||||
String? clubId,
|
||||
bool includeGuests = false,
|
||||
}) async {
|
||||
if (_token == null) {
|
||||
throw Exception('인증 정보가 필요합니다');
|
||||
}
|
||||
// clubId 확보
|
||||
String? cid = clubId ?? _clubId;
|
||||
if (cid == null) {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
cid = prefs.getString(ApiConfig.clubIdKey);
|
||||
} catch (_) {}
|
||||
}
|
||||
if (cid == null) {
|
||||
throw Exception('클럽 정보가 필요합니다');
|
||||
}
|
||||
|
||||
try {
|
||||
await _apiService.post(
|
||||
'${ApiConfig.clubs}${ApiConfig.events}/rollback-import',
|
||||
data: {
|
||||
'eventId': int.tryParse(eventId) ?? eventId,
|
||||
'clubId': int.tryParse(cid) ?? cid,
|
||||
'includeGuests': includeGuests,
|
||||
},
|
||||
);
|
||||
// 로컬 캐시 반영
|
||||
_events.removeWhere((e) => e.id == eventId);
|
||||
notifyListeners();
|
||||
return true;
|
||||
} catch (e) {
|
||||
throw Exception('임포트 롤백에 실패했습니다: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// 참가자 관리 기능
|
||||
// 이벤트 참가자 목록 가져오기
|
||||
Future<List<Participant>> fetchEventParticipants(String eventId) async {
|
||||
@@ -284,8 +420,22 @@ class EventService with ChangeNotifier {
|
||||
String participantId,
|
||||
Map<String, dynamic> updates,
|
||||
) async {
|
||||
if (_token == null || _clubId == null) {
|
||||
throw Exception('인증 또는 클럽 정보가 필요합니다');
|
||||
if (_token == null) {
|
||||
throw Exception('인증 정보가 필요합니다');
|
||||
}
|
||||
|
||||
// 업데이트 전에 clubId가 비어 있으면 SharedPreferences에서 보강 시도
|
||||
if (_clubId == null) {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final cid = prefs.getString(ApiConfig.clubIdKey);
|
||||
if (cid != null) {
|
||||
_clubId = cid;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
if (_clubId == null) {
|
||||
throw Exception('클럽 정보가 아직 설정되지 않았습니다. 클럽 선택 후 다시 시도해 주세요.');
|
||||
}
|
||||
|
||||
_isLoading = true;
|
||||
@@ -294,9 +444,37 @@ class EventService with ChangeNotifier {
|
||||
try {
|
||||
// 업데이트 페이로드도 동일한 규칙으로 정규화
|
||||
final normalized = _sanitizeParticipantPayload(updates);
|
||||
|
||||
// 서버는 update 시 memberId가 필요함 → 로컬 캐시에서 찾아 보강
|
||||
String? memberId = normalized['memberId']?.toString();
|
||||
if (memberId == null || memberId.isEmpty) {
|
||||
final existing = _participants.firstWhere(
|
||||
(p) => p.id == participantId,
|
||||
orElse: () => Participant(
|
||||
id: participantId,
|
||||
eventId: eventId,
|
||||
memberId: '',
|
||||
status: 'pending',
|
||||
isPaid: false,
|
||||
),
|
||||
);
|
||||
if (existing.memberId.isNotEmpty) {
|
||||
memberId = existing.memberId;
|
||||
}
|
||||
}
|
||||
if (memberId == null || memberId.isEmpty) {
|
||||
throw Exception('참가자 수정에 필요한 memberId를 찾을 수 없습니다. 목록 새로고침 후 다시 시도해 주세요.');
|
||||
}
|
||||
|
||||
final payload = {
|
||||
'id': participantId,
|
||||
'clubId': _clubId,
|
||||
'memberId': memberId,
|
||||
...normalized,
|
||||
};
|
||||
final data = await _apiService.put(
|
||||
'${ApiConfig.clubs}/events/$eventId/participants/$participantId',
|
||||
data: normalized,
|
||||
'${ApiConfig.clubs}/events/$eventId/participants',
|
||||
data: payload,
|
||||
);
|
||||
|
||||
final updatedParticipant = Participant.fromJson(data['participant']);
|
||||
@@ -316,6 +494,10 @@ class EventService with ChangeNotifier {
|
||||
} catch (e) {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
final msg = e.toString();
|
||||
if (msg.contains('404') || msg.contains('Cannot PUT')) {
|
||||
throw Exception('참가자 업데이트 경로가 서버에서 지원되지 않거나 대상이 없습니다(404). 목록 새로고침 후 다시 시도하거나 서버 라우트를 확인해주세요. 상세: $e');
|
||||
}
|
||||
throw Exception('참가자 정보 업데이트에 실패했습니다: $e');
|
||||
}
|
||||
}
|
||||
@@ -324,18 +506,15 @@ class EventService with ChangeNotifier {
|
||||
Map<String, dynamic> _sanitizeParticipantPayload(Map<String, dynamic> data) {
|
||||
final Map<String, dynamic> out = Map<String, dynamic>.from(data);
|
||||
|
||||
// 상태 표준화: 구버전/철자 변형 호환
|
||||
// 상태 표준화: 구버전/철자 변형 호환 + 서버 Enum 호환 임시 매핑
|
||||
final statusRaw = (out['status']?.toString().trim() ?? '').toLowerCase();
|
||||
if (statusRaw.isNotEmpty) {
|
||||
String status = statusRaw;
|
||||
if (status == 'cancelled') status = 'canceled';
|
||||
// 허용 목록: registered, pending, confirmed, canceled
|
||||
if (![
|
||||
'registered',
|
||||
'pending',
|
||||
'confirmed',
|
||||
'canceled',
|
||||
].contains(status)) {
|
||||
// 서버는 registered를 Enum으로 지원하지 않음 → 요청 전 'pending'으로 보정
|
||||
if (status == 'registered') status = 'pending';
|
||||
// 허용 목록: pending, confirmed, canceled (registered는 위에서 보정)
|
||||
if (!['pending', 'confirmed', 'canceled'].contains(status)) {
|
||||
// 허용 외 값은 안전하게 pending 처리
|
||||
status = 'pending';
|
||||
}
|
||||
@@ -408,20 +587,43 @@ class EventService with ChangeNotifier {
|
||||
// 점수 관리 기능
|
||||
// 이벤트 점수 목록 가져오기
|
||||
Future<List<Score>> fetchEventScores(String eventId) async {
|
||||
if (_token == null || _clubId == null) {
|
||||
throw Exception('인증 또는 클럽 정보가 필요합니다');
|
||||
if (_token == null) {
|
||||
throw Exception('인증 정보가 필요합니다');
|
||||
}
|
||||
|
||||
// 초기 진입 시점에 _clubId가 아직 설정되지 않았을 수 있으므로, 우선 SharedPreferences에서 시도
|
||||
if (_clubId == null) {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final cid = prefs.getString(ApiConfig.clubIdKey);
|
||||
if (cid != null) {
|
||||
_clubId = cid;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// 그래도 없으면 점수는 조용히 빈 목록으로 처리하여 첫 진입 오류를 방지
|
||||
if (_clubId == null) {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
return [];
|
||||
}
|
||||
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final data = await _apiService.post(
|
||||
'${ApiConfig.clubs}/events/scores',
|
||||
data: {'eventId': eventId},
|
||||
// 서버 라우트: GET /api/club/events/:id/scores
|
||||
// requireFeature 미들웨어가 clubId를 요구하므로 query로 전달
|
||||
final data = await _apiService.get(
|
||||
'${ApiConfig.clubs}/events/$eventId/scores',
|
||||
queryParameters: {'clubId': _clubId},
|
||||
);
|
||||
|
||||
final List<dynamic> scoresData = data['scores'] ?? [];
|
||||
// 서버가 배열 자체를 반환하거나 { scores: [...] } 형태로 반환할 수 있어 모두 처리
|
||||
final List<dynamic> scoresData = (data is List)
|
||||
? data
|
||||
: (data['scores'] ?? []);
|
||||
_scores = scoresData
|
||||
.map((scoreData) => Score.fromJson(scoreData))
|
||||
.toList();
|
||||
@@ -433,6 +635,11 @@ class EventService with ChangeNotifier {
|
||||
} catch (e) {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
final msg = e.toString();
|
||||
// 서버 미들웨어에서 clubId 누락 시 400을 반환할 수 있는데, 초기 진입에서는 조용히 무시하고 빈 목록 반환
|
||||
if (msg.contains('400') && (msg.contains('클럽 정보가 필요합니다') || msg.contains('club'))) {
|
||||
return [];
|
||||
}
|
||||
throw Exception('점수 목록을 불러오는데 실패했습니다: $e');
|
||||
}
|
||||
}
|
||||
@@ -447,9 +654,18 @@ class EventService with ChangeNotifier {
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
// 빈 문자열을 명시적 null로 변환해 서버에서 정확히 처리되도록 함
|
||||
final sanitized = Map<String, dynamic>.from(scoreData)..updateAll((key, value) {
|
||||
if (value is String && value.trim().isEmpty) return null;
|
||||
return value;
|
||||
});
|
||||
|
||||
final data = await _apiService.post(
|
||||
'${ApiConfig.clubs}/events/$eventId/scores',
|
||||
data: scoreData,
|
||||
data: {
|
||||
'clubId': _clubId,
|
||||
...sanitized,
|
||||
},
|
||||
);
|
||||
|
||||
final newScore = Score.fromJson(data['score']);
|
||||
@@ -481,9 +697,45 @@ class EventService with ChangeNotifier {
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
// 캐시에서 대상 점수 찾아 gameNumber 확보
|
||||
final Score current = _scores.firstWhere(
|
||||
(s) => s.id == scoreId,
|
||||
orElse: () => Score(
|
||||
id: scoreId,
|
||||
memberId: '',
|
||||
eventId: eventId,
|
||||
clubId: _clubId ?? '',
|
||||
frames: const [],
|
||||
totalScore: 0,
|
||||
date: DateTime.now(),
|
||||
),
|
||||
);
|
||||
|
||||
// 업데이트 시에도 빈 문자열을 null로 변환
|
||||
final sanitized = Map<String, dynamic>.from(updates)..updateAll((key, value) {
|
||||
if (value is String && value.trim().isEmpty) return null;
|
||||
return value;
|
||||
});
|
||||
|
||||
// 서버 호환: totalScore -> score 매핑, 필수 필드 포함
|
||||
final Map<String, dynamic> payload = {
|
||||
'clubId': _clubId,
|
||||
'eventId': eventId,
|
||||
'scoreId': scoreId,
|
||||
'gameNumber': sanitized['gameNumber'] ?? current.gameNumber,
|
||||
'score': sanitized['score'] ?? sanitized['totalScore'],
|
||||
// 날짜 보존: 서버가 업데이트 시 현재 시각으로 덮어쓰지 않도록 기존 날짜 전송
|
||||
'date': (sanitized['date'] ?? current.date.toIso8601String()),
|
||||
// 참고: 일부 라우트는 totalScore도 허용할 수 있어 함께 전달(서버가 무시해도 무해)
|
||||
if (sanitized.containsKey('totalScore')) 'totalScore': sanitized['totalScore'],
|
||||
if (sanitized.containsKey('handicap')) 'handicap': sanitized['handicap'],
|
||||
if (sanitized.containsKey('notes')) 'notes': sanitized['notes'],
|
||||
if (sanitized.containsKey('frames')) 'frames': sanitized['frames'],
|
||||
}..removeWhere((key, value) => value == null);
|
||||
|
||||
final data = await _apiService.put(
|
||||
'${ApiConfig.clubs}/events/$eventId/scores/$scoreId',
|
||||
data: updates,
|
||||
data: payload,
|
||||
);
|
||||
|
||||
final updatedScore = Score.fromJson(data['score']);
|
||||
@@ -541,17 +793,73 @@ class EventService with ChangeNotifier {
|
||||
}
|
||||
|
||||
try {
|
||||
final data = await _apiService.post(
|
||||
'${ApiConfig.events}/teams',
|
||||
data: {'eventId': eventId},
|
||||
// 백엔드 라우트: GET /api/club/events/:id/teams
|
||||
final data = await _apiService.get(
|
||||
'${ApiConfig.clubs}/events/$eventId/teams',
|
||||
queryParameters: { 'clubId': _clubId },
|
||||
);
|
||||
|
||||
final List<dynamic> teamsData = data['teams'] ?? [];
|
||||
_teams = teamsData.map((teamData) => Team.fromJson(teamData)).toList();
|
||||
final List<dynamic> teamsData = (data is List) ? data : (data['teams'] ?? []);
|
||||
// 서버가 'memberIds' 또는 'members' 배열(참가자/멤버 참조)로 반환할 수 있어 모두 처리
|
||||
_teams = teamsData.map((teamData) {
|
||||
// 표준 경로: Team.fromJson에서 memberIds가 바로 존재
|
||||
final hasMemberIds = teamData is Map && teamData['memberIds'] != null;
|
||||
if (hasMemberIds) {
|
||||
return Team.fromJson(teamData as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
// 대체 경로: members 배열로 내려오는 경우 각 요소에서 memberId 또는 participantId를 memberId로 매핑
|
||||
if (teamData is Map && teamData['members'] is List) {
|
||||
final List members = teamData['members'] as List;
|
||||
final List<String> memberIds = [];
|
||||
for (final m in members) {
|
||||
if (m is Map) {
|
||||
// 우선 memberId가 있으면 사용
|
||||
final mid = m['memberId']?.toString();
|
||||
if (mid != null && mid.isNotEmpty) {
|
||||
memberIds.add(mid);
|
||||
continue;
|
||||
}
|
||||
// 없으면 participantId(id)로 참가자 캐시에서 memberId를 조회
|
||||
final pid = (m['participantId'] ?? m['id'])?.toString();
|
||||
if (pid != null && pid.isNotEmpty) {
|
||||
try {
|
||||
final p = _participants.firstWhere((pp) => pp.id == pid);
|
||||
if (p.memberId.isNotEmpty) memberIds.add(p.memberId);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
return Team(
|
||||
id: teamData['id']?.toString() ?? '',
|
||||
eventId: teamData['eventId']?.toString() ?? eventId,
|
||||
name: teamData['name']?.toString() ?? '',
|
||||
memberIds: memberIds,
|
||||
description: teamData['description']?.toString(),
|
||||
createdAt: teamData['createdAt'] != null ? DateTime.tryParse(teamData['createdAt'].toString()) : null,
|
||||
updatedAt: teamData['updatedAt'] != null ? DateTime.tryParse(teamData['updatedAt'].toString()) : null,
|
||||
);
|
||||
}
|
||||
|
||||
// 알 수 없는 포맷은 방어적으로 기본 파싱
|
||||
return Team.fromJson(Map<String, dynamic>.from(teamData as Map));
|
||||
}).toList();
|
||||
|
||||
notifyListeners();
|
||||
return _teams;
|
||||
} catch (e) {
|
||||
// 서버 미지원/404 시 로컬 저장소에서 로드 시도
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final key = 'local_teams_' + eventId;
|
||||
final raw = prefs.getString(key);
|
||||
if (raw != null && raw.isNotEmpty) {
|
||||
final List<dynamic> jsonList = jsonDecode(raw);
|
||||
_teams = jsonList.map((t) => Team.fromJson(t)).toList();
|
||||
notifyListeners();
|
||||
return _teams;
|
||||
}
|
||||
} catch (_) {}
|
||||
throw Exception('팀 목록을 불러오는데 실패했습니다: $e');
|
||||
}
|
||||
}
|
||||
@@ -563,12 +871,13 @@ class EventService with ChangeNotifier {
|
||||
}
|
||||
|
||||
try {
|
||||
final data = await _apiService.post(
|
||||
'${ApiConfig.events}/teams',
|
||||
data: {'eventId': eventId},
|
||||
// 백엔드 라우트: GET /api/club/events/:id/teams
|
||||
final data = await _apiService.get(
|
||||
'${ApiConfig.clubs}/events/$eventId/teams',
|
||||
queryParameters: { 'clubId': _clubId },
|
||||
);
|
||||
|
||||
final List<dynamic> teamsData = data['teams'] ?? [];
|
||||
final List<dynamic> teamsData = (data is List) ? data : (data['teams'] ?? []);
|
||||
return teamsData.isNotEmpty;
|
||||
} catch (e) {
|
||||
print('팀 확인 실패: $e');
|
||||
@@ -619,8 +928,11 @@ class EventService with ChangeNotifier {
|
||||
|
||||
try {
|
||||
await _apiService.post(
|
||||
'${ApiConfig.clubs}/events/$eventId/teams/save',
|
||||
data: {'teams': teams.map((team) => team.toJson()).toList()},
|
||||
'${ApiConfig.clubs}/events/$eventId/teams',
|
||||
data: {
|
||||
'clubId': _clubId,
|
||||
'teams': teams.map((team) => team.toJson()).toList(),
|
||||
},
|
||||
);
|
||||
|
||||
_teams = teams;
|
||||
@@ -630,9 +942,21 @@ class EventService with ChangeNotifier {
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
throw Exception('팀 저장에 실패했습니다: $e');
|
||||
// 서버 미지원/404 시 로컬로 저장
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final key = 'local_teams_' + eventId;
|
||||
final raw = jsonEncode(teams.map((t) => t.toJson()).toList());
|
||||
await prefs.setString(key, raw);
|
||||
_teams = teams;
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
return true;
|
||||
} catch (e2) {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
throw Exception('팀 저장에 실패했습니다(로컬 폴백도 실패): $e2');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -733,4 +1057,227 @@ class EventService with ChangeNotifier {
|
||||
throw Exception('이벤트 복제에 실패했습니다: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// =============================
|
||||
// 파일 업로드 기반 일괄 생성 플로우 (Vue 호환)
|
||||
// =============================
|
||||
|
||||
// 파일 업로드: /club/events/upload
|
||||
Future<Map<String, dynamic>> uploadEventFile(File file) async {
|
||||
if (_token == null) {
|
||||
throw Exception('인증 정보가 필요합니다');
|
||||
}
|
||||
try {
|
||||
// clubId 보강: 아직 설정 전인 경우 SharedPreferences에서 로드
|
||||
if (_clubId == null) {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final cid = prefs.getString(ApiConfig.clubIdKey);
|
||||
if (cid != null && cid.isNotEmpty) {
|
||||
_clubId = cid;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
if (_clubId == null || _clubId!.isEmpty) {
|
||||
throw Exception('클럽 정보가 필요합니다. 클럽을 먼저 선택해 주세요.');
|
||||
}
|
||||
final fileName = file.path.split('/').last;
|
||||
final formData = FormData.fromMap({
|
||||
'file': await MultipartFile.fromFile(file.path, filename: fileName),
|
||||
'clubId': _clubId,
|
||||
});
|
||||
final data = await _apiService.post(
|
||||
'${ApiConfig.clubs}${ApiConfig.events}/upload?clubId=${Uri.encodeComponent(_clubId!)}',
|
||||
data: formData,
|
||||
);
|
||||
if (data is Map) {
|
||||
final map = Map<String, dynamic>.from(data as Map);
|
||||
// 서버 응답: { message, file: { filename, originalname, ... } }
|
||||
if (map['file'] is Map) {
|
||||
final fm = Map<String, dynamic>.from(map['file'] as Map);
|
||||
final out = <String, dynamic>{
|
||||
'filename': fm['filename']?.toString(),
|
||||
};
|
||||
if (fm.containsKey('originalname')) {
|
||||
final orig = fm['originalname']?.toString();
|
||||
out['originalname'] = orig;
|
||||
// URL 안전 인코딩 버전 (다운로드 링크 등에 사용)
|
||||
if (orig != null) {
|
||||
out['originalnameEncoded'] = Uri.encodeComponent(orig);
|
||||
// 유니코드 이스케이프 버전 (로그/표시 안전)
|
||||
out['originalnameUnicode'] = _toUnicodeEscape(orig);
|
||||
}
|
||||
}
|
||||
if (fm.containsKey('mimetype')) out['mimetype'] = fm['mimetype'];
|
||||
if (fm.containsKey('size')) out['size'] = fm['size'];
|
||||
if (fm.containsKey('path')) out['path'] = fm['path'];
|
||||
if (fm.containsKey('clubId')) out['clubId'] = fm['clubId'];
|
||||
return out;
|
||||
}
|
||||
// 혹시 서버가 평면으로 줄 경우도 방어
|
||||
if (map.containsKey('filename')) return Map<String, dynamic>.from(map);
|
||||
}
|
||||
return {'filename': data.toString()};
|
||||
} catch (e) {
|
||||
throw Exception('파일 업로드 실패: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// 비 ASCII 문자들을 \uXXXX 형태로 이스케이프
|
||||
String _toUnicodeEscape(String input) {
|
||||
final StringBuffer sb = StringBuffer();
|
||||
for (final int codeUnit in input.codeUnits) {
|
||||
if (codeUnit >= 0x20 && codeUnit <= 0x7E) {
|
||||
sb.writeCharCode(codeUnit);
|
||||
} else {
|
||||
sb.write('\\u');
|
||||
sb.write(codeUnit.toRadixString(16).padLeft(4, '0'));
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
// 파일 파싱 미리보기: /club/events/parse-preview
|
||||
Future<Map<String, dynamic>> parseEventFile({
|
||||
required String filename,
|
||||
String? sheetName,
|
||||
}) async {
|
||||
if (_token == null) {
|
||||
throw Exception('인증 정보가 필요합니다');
|
||||
}
|
||||
try {
|
||||
final payload = <String, dynamic>{
|
||||
'filename': filename,
|
||||
if (_clubId != null) 'clubId': _clubId,
|
||||
if (sheetName != null) 'sheetName': sheetName,
|
||||
};
|
||||
final data = await _apiService.post(
|
||||
'${ApiConfig.clubs}${ApiConfig.events}/parse-preview',
|
||||
data: payload,
|
||||
);
|
||||
// 서버는 { message, data: { participants: [...], gameCount, inferredEventInfo, sheets? } } 형태로 응답
|
||||
// UI는 최상위에서 participants 등을 기대하므로 data 래퍼를 풀어 반환
|
||||
if (data is! Map) return {'raw': data};
|
||||
final map = Map<String, dynamic>.from(data);
|
||||
if (map['data'] is Map) {
|
||||
return Map<String, dynamic>.from(map['data']);
|
||||
}
|
||||
return map;
|
||||
} catch (e) {
|
||||
throw Exception('파일 미리보기 파싱 실패: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// 파일 데이터 저장: /club/events/save-file-data
|
||||
Future<Map<String, dynamic>> saveEventFileData({
|
||||
required Map<String, dynamic> eventData,
|
||||
required List<dynamic> participants,
|
||||
}) async {
|
||||
if (_token == null) {
|
||||
throw Exception('인증 정보가 필요합니다');
|
||||
}
|
||||
try {
|
||||
// 보장: clubId 확보 (메모리에 없으면 SharedPreferences에서 로드)
|
||||
if (_clubId == null) {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final cid = prefs.getString(ApiConfig.clubIdKey);
|
||||
if (cid != null && cid.isNotEmpty) {
|
||||
_clubId = cid;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// clubId 보강 및 날짜/빈 문자열 정리
|
||||
final payloadEvent = Map<String, dynamic>.from(eventData);
|
||||
payloadEvent['clubId'] = _clubId ?? payloadEvent['clubId'];
|
||||
payloadEvent.updateAll((key, value) {
|
||||
if (value is DateTime) return value.toIso8601String();
|
||||
if (value is String && value.trim().isEmpty) return null;
|
||||
return value;
|
||||
});
|
||||
|
||||
// 서버 ENUM 호환을 위한 상태/유형 정규화
|
||||
String? rawStatus = payloadEvent['status']?.toString();
|
||||
if (rawStatus != null && rawStatus.isNotEmpty) {
|
||||
final s = rawStatus.toLowerCase();
|
||||
if (['active', '활성'].contains(s)) payloadEvent['status'] = 'active';
|
||||
else if (['ready', '대기', '준비', 'draft'].contains(s)) payloadEvent['status'] = 'ready';
|
||||
else if (['completed', '완료', 'done', 'finished'].contains(s)) payloadEvent['status'] = 'completed';
|
||||
else if (['canceled', 'cancelled', '취소'].contains(s)) payloadEvent['status'] = 'canceled';
|
||||
else if (['deleted', '삭제'].contains(s)) payloadEvent['status'] = 'deleted';
|
||||
else payloadEvent['status'] = 'ready';
|
||||
}
|
||||
|
||||
String? rawType = payloadEvent['eventType']?.toString();
|
||||
if (rawType != null && rawType.isNotEmpty) {
|
||||
final t = rawType.toLowerCase();
|
||||
if (['regular','일반'].contains(t)) payloadEvent['eventType'] = 'regular';
|
||||
else if (['lightning','번개'].contains(t)) payloadEvent['eventType'] = 'lightning';
|
||||
else if (['practice','연습'].contains(t)) payloadEvent['eventType'] = 'practice';
|
||||
else if (['exchange','교류전'].contains(t)) payloadEvent['eventType'] = 'exchange';
|
||||
else if (['event','행사'].contains(t)) payloadEvent['eventType'] = 'event';
|
||||
else payloadEvent['eventType'] = 'other';
|
||||
}
|
||||
|
||||
// 참가자 페이로드 정리: teamNumber를 숫자로 정규화, handicap 숫자 보정
|
||||
final List<dynamic> sanitizedParticipants = participants.map((p) {
|
||||
if (p is Map) {
|
||||
final out = Map<String, dynamic>.from(p);
|
||||
// teamNumber: '2팀' 같은 문자열에서 숫자만 추출
|
||||
final tn = out['teamNumber'];
|
||||
if (tn != null) {
|
||||
final m = RegExp(r"\d+").firstMatch(tn.toString());
|
||||
out['teamNumber'] = m != null ? int.tryParse(m.group(0)!) : null;
|
||||
}
|
||||
// handicap: 숫자 보정
|
||||
if (out.containsKey('handicap')) {
|
||||
final hv = out['handicap'];
|
||||
out['handicap'] = (hv is num)
|
||||
? hv.toInt()
|
||||
: int.tryParse(hv?.toString() ?? '') ?? 0;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return p;
|
||||
}).toList();
|
||||
|
||||
final data = await _apiService.post(
|
||||
'${ApiConfig.clubs}${ApiConfig.events}/save-file-data',
|
||||
data: {
|
||||
'eventData': payloadEvent,
|
||||
'participants': sanitizedParticipants,
|
||||
if (_clubId != null) 'clubId': _clubId,
|
||||
},
|
||||
);
|
||||
if (data is! Map) return {'raw': data};
|
||||
return Map<String, dynamic>.from(data);
|
||||
} catch (e) {
|
||||
throw Exception('파일 데이터 저장 실패: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// 업로드 파일 삭제: /club/events/delete-file
|
||||
Future<bool> deleteUploadedFile(String filename) async {
|
||||
if (_token == null) {
|
||||
throw Exception('인증 정보가 필요합니다');
|
||||
}
|
||||
try {
|
||||
await _apiService.post(
|
||||
'${ApiConfig.clubs}${ApiConfig.events}/delete-file',
|
||||
data: {
|
||||
'filename': filename,
|
||||
if (_clubId != null) 'clubId': _clubId,
|
||||
},
|
||||
);
|
||||
return true;
|
||||
} catch (e) {
|
||||
// 실패해도 치명적이지 않음
|
||||
if (kDebugMode) {
|
||||
print('임시 파일 삭제 실패: $e');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -38,32 +38,60 @@ class BadgeStyles {
|
||||
return _chip(type, bg, icon);
|
||||
}
|
||||
|
||||
// 게스트용 초소형 원형 배지 (이니셜 'G')
|
||||
static Widget guestSmall({double size = 18, Color? bg, Color? fg}) {
|
||||
final Color bgColor = bg ?? neutralBg;
|
||||
final Color fgColor = fg ?? Colors.black87;
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
'G',
|
||||
style: TextStyle(
|
||||
fontSize: size * 0.62,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: fgColor,
|
||||
height: 1.0,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 참가 상태 컬러/아이콘 매핑
|
||||
static Chip participantStatus(String status) {
|
||||
late final Color bg;
|
||||
late final IconData icon;
|
||||
switch (status) {
|
||||
case '등록됨':
|
||||
bg = neutralBg;
|
||||
icon = Icons.how_to_reg;
|
||||
break;
|
||||
case '확인됨':
|
||||
bg = successBg;
|
||||
icon = Icons.check_circle;
|
||||
break;
|
||||
case '취소됨':
|
||||
bg = dangerBg;
|
||||
icon = Icons.cancel;
|
||||
break;
|
||||
case '참석함':
|
||||
bg = primaryBg;
|
||||
icon = Icons.event_available;
|
||||
break;
|
||||
default:
|
||||
bg = neutralBg;
|
||||
icon = Icons.help_outline;
|
||||
// status는 한국어 라벨 또는 API 값(pending/confirmed/canceled/attended)로 들어올 수 있음
|
||||
final s = status.toLowerCase();
|
||||
String label;
|
||||
if (s == 'pending' || s == '참가예정' || s == '등록됨' || s == '대기') {
|
||||
bg = neutralBg;
|
||||
icon = Icons.how_to_reg;
|
||||
label = '예정';
|
||||
} else if (s == 'confirmed' || s == '참가확정' || s == '확인됨') {
|
||||
bg = successBg;
|
||||
icon = Icons.check_circle;
|
||||
label = '확정';
|
||||
} else if (s == 'canceled' || s == '취소' || s == '취소됨') {
|
||||
bg = dangerBg;
|
||||
icon = Icons.cancel;
|
||||
label = '취소';
|
||||
} else if (s == 'attended' || s == '참석' || s == '참석함') {
|
||||
bg = primaryBg;
|
||||
icon = Icons.event_available;
|
||||
label = '참석';
|
||||
} else {
|
||||
bg = neutralBg;
|
||||
icon = Icons.help_outline;
|
||||
label = status;
|
||||
}
|
||||
return _chip(status, bg, icon);
|
||||
return _chip(label, bg, icon);
|
||||
}
|
||||
|
||||
// 회원 상태 컬러/아이콘 매핑 (라벨 기준: 활성/비활성/정지/삭제됨)
|
||||
@@ -97,7 +125,7 @@ class BadgeStyles {
|
||||
// 결제 상태 컬러/아이콘 매핑
|
||||
static Chip payment(bool isPaid) {
|
||||
return _chip(
|
||||
isPaid ? '결제완료' : '미결제',
|
||||
isPaid ? '납부' : '미납',
|
||||
isPaid ? successBg : dangerBg,
|
||||
isPaid ? Icons.check_circle : Icons.cancel,
|
||||
);
|
||||
@@ -107,28 +135,32 @@ class BadgeStyles {
|
||||
static Chip eventStatus(String status) {
|
||||
late final Color bg;
|
||||
late final IconData icon;
|
||||
switch (status) {
|
||||
case '활성':
|
||||
bg = successBg;
|
||||
icon = Icons.check_circle;
|
||||
break;
|
||||
case '대기':
|
||||
bg = Colors.amber.shade100;
|
||||
icon = Icons.hourglass_top;
|
||||
break;
|
||||
case '취소':
|
||||
bg = dangerBg;
|
||||
icon = Icons.cancel;
|
||||
break;
|
||||
case '완료':
|
||||
bg = Colors.grey.shade300;
|
||||
icon = Icons.done_all;
|
||||
break;
|
||||
default:
|
||||
bg = neutralBg;
|
||||
icon = Icons.event;
|
||||
// DB 원시값(영문) 또는 한국어 라벨을 모두 지원하도록 정규화
|
||||
final s = status.toLowerCase().trim();
|
||||
String label = status; // 표시용 라벨 (한국어)
|
||||
if (s == 'active' || s == 'enabled' || s == '활성') {
|
||||
label = '활성';
|
||||
bg = successBg;
|
||||
icon = Icons.check_circle;
|
||||
} else if (s == 'pending' || s == 'draft' || s == 'scheduled' || s == 'waiting' || s == '대기') {
|
||||
label = '대기';
|
||||
bg = Colors.amber.shade100;
|
||||
icon = Icons.hourglass_top;
|
||||
} else if (s == 'canceled' || s == 'cancelled' || s == '취소') {
|
||||
label = '취소';
|
||||
bg = dangerBg;
|
||||
icon = Icons.cancel;
|
||||
} else if (s == 'completed' || s == 'done' || s == 'finished' || s == '완료') {
|
||||
label = '완료';
|
||||
bg = Colors.grey.shade300;
|
||||
icon = Icons.done_all;
|
||||
} else {
|
||||
// 알 수 없는 상태는 중립 처리
|
||||
label = status;
|
||||
bg = neutralBg;
|
||||
icon = Icons.event;
|
||||
}
|
||||
return _chip(status, bg, icon);
|
||||
return _chip(label, bg, icon);
|
||||
}
|
||||
|
||||
static Chip _chip(String text, Color bg, IconData icon) {
|
||||
|
||||
@@ -5,6 +5,97 @@ const Map<String, String> roleOptions = {
|
||||
'user': '일반 사용자',
|
||||
};
|
||||
|
||||
// -------------------------------
|
||||
// Strong enums for client usage
|
||||
// -------------------------------
|
||||
|
||||
/// 참가자 상태 (서버 enum과 1:1 매칭)
|
||||
enum ParticipantStatus { pending, confirmed, canceled, attended }
|
||||
|
||||
extension ParticipantStatusX on ParticipantStatus {
|
||||
String get apiValue {
|
||||
switch (this) {
|
||||
case ParticipantStatus.pending:
|
||||
return 'pending';
|
||||
case ParticipantStatus.confirmed:
|
||||
return 'confirmed';
|
||||
case ParticipantStatus.canceled:
|
||||
return 'canceled';
|
||||
case ParticipantStatus.attended:
|
||||
return 'attended';
|
||||
}
|
||||
}
|
||||
|
||||
String get labelKo {
|
||||
switch (this) {
|
||||
case ParticipantStatus.pending:
|
||||
return '대기';
|
||||
case ParticipantStatus.confirmed:
|
||||
return '확인됨';
|
||||
case ParticipantStatus.canceled:
|
||||
return '취소됨';
|
||||
case ParticipantStatus.attended:
|
||||
return '참석';
|
||||
}
|
||||
}
|
||||
|
||||
static ParticipantStatus? from(String? value) {
|
||||
switch ((value ?? '').toLowerCase()) {
|
||||
case 'pending':
|
||||
return ParticipantStatus.pending;
|
||||
case 'confirmed':
|
||||
return ParticipantStatus.confirmed;
|
||||
case 'canceled':
|
||||
return ParticipantStatus.canceled;
|
||||
case 'attended':
|
||||
return ParticipantStatus.attended;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 결제 상태 (서버 enum과 1:1 매칭)
|
||||
enum PaymentStatus { unpaid, paid }
|
||||
|
||||
extension PaymentStatusX on PaymentStatus {
|
||||
String get apiValue {
|
||||
switch (this) {
|
||||
case PaymentStatus.unpaid:
|
||||
return 'unpaid';
|
||||
case PaymentStatus.paid:
|
||||
return 'paid';
|
||||
}
|
||||
}
|
||||
|
||||
String get labelKo {
|
||||
switch (this) {
|
||||
case PaymentStatus.unpaid:
|
||||
return '미납';
|
||||
case PaymentStatus.paid:
|
||||
return '납부';
|
||||
}
|
||||
}
|
||||
|
||||
static PaymentStatus? from(String? value) {
|
||||
switch ((value ?? '').toLowerCase()) {
|
||||
case 'unpaid':
|
||||
return PaymentStatus.unpaid;
|
||||
case 'paid':
|
||||
return PaymentStatus.paid;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Backward-compatible helpers for non-enum callsites
|
||||
String toKoreanParticipantStatus(String? value) =>
|
||||
(ParticipantStatusX.from(value) ?? ParticipantStatus.pending).labelKo;
|
||||
|
||||
String toKoreanPaymentStatus(String? value) =>
|
||||
(PaymentStatusX.from(value) ?? PaymentStatus.unpaid).labelKo;
|
||||
|
||||
// 성별 옵션
|
||||
const Map<String, String> genderOptions = {
|
||||
'male': '남성',
|
||||
@@ -48,9 +139,10 @@ const Map<String, String> eventStatusOptions = {
|
||||
const Map<String, String> eventTypeOptions = {
|
||||
'regular': '정기전',
|
||||
'exchange': '교류전',
|
||||
'tournament': '토너먼트',
|
||||
'tournament': '대회',
|
||||
'lightning': '번개',
|
||||
'friendly': '친선전',
|
||||
'event': '이벤트',
|
||||
'other': '기타',
|
||||
};
|
||||
|
||||
|
||||
@@ -23,11 +23,39 @@ class EventCsvParser {
|
||||
};
|
||||
}
|
||||
|
||||
final headers = _parseCsvLine(lines.first)
|
||||
.map((e) => e.trim().toLowerCase())
|
||||
final headersRaw = _parseCsvLine(lines.first);
|
||||
final headers = headersRaw
|
||||
.map((e) => e.trim())
|
||||
.toList();
|
||||
|
||||
if (!headers.contains('title') || !headers.contains('startdate')) {
|
||||
// 한글 헤더를 영문 키로 정규화하기 위한 매핑
|
||||
String _normalizeHeader(String raw) {
|
||||
final v = raw.trim().toLowerCase();
|
||||
// 공백 제거 버전도 별도로 준비
|
||||
final vn = v.replaceAll(' ', '');
|
||||
// 한국어 -> 영문 매핑
|
||||
if (v == '제목') return 'title';
|
||||
if (v == '시작일') return 'startDate';
|
||||
if (v == '종료일') return 'endDate';
|
||||
if (v == '설명') return 'description';
|
||||
if (v == '장소') return 'location';
|
||||
if (v == '상태') return 'status';
|
||||
if (v == '유형') return 'type';
|
||||
if (v == '게임 수' || vn == '게임수') return 'gameCount';
|
||||
if (v == '참가비') return 'participantFee';
|
||||
if (v == '신청마감' || v == '신청 마감') return 'registrationDeadline';
|
||||
if (v == '최대 참가자' || vn == '최대참가자') return 'maxParticipants';
|
||||
|
||||
// 기존 영문 키들 정규화
|
||||
if (vn == 'maxparticipants') return 'maxParticipants';
|
||||
if (vn == 'startdate') return 'startDate';
|
||||
if (vn == 'enddate') return 'endDate';
|
||||
return raw.trim();
|
||||
}
|
||||
|
||||
final normalizedHeaders = headers.map(_normalizeHeader).toList();
|
||||
|
||||
if (!normalizedHeaders.contains('title') || !normalizedHeaders.contains('startDate')) {
|
||||
return {
|
||||
'processedRows': 0,
|
||||
'validEvents': <Map<String, dynamic>>[],
|
||||
@@ -52,26 +80,20 @@ class EventCsvParser {
|
||||
processedRows++;
|
||||
|
||||
final event = <String, dynamic>{};
|
||||
for (var j = 0; j < headers.length && j < row.length; j++) {
|
||||
final header = headers[j];
|
||||
for (var j = 0; j < normalizedHeaders.length && j < row.length; j++) {
|
||||
final header = normalizedHeaders[j];
|
||||
var value = row[j].trim();
|
||||
|
||||
// 헤더 정규화
|
||||
var normalizedHeader = header;
|
||||
if (header == 'maxparticipants') normalizedHeader = 'maxParticipants';
|
||||
if (header == 'startdate') normalizedHeader = 'startDate';
|
||||
if (header == 'enddate') normalizedHeader = 'endDate';
|
||||
|
||||
if (normalizedHeader == 'startDate' || normalizedHeader == 'endDate') {
|
||||
if (header == 'startDate' || header == 'endDate') {
|
||||
// 날짜 문자열은 그대로 전달하고, 화면/서버 측에서 ISO 변환/검증 처리
|
||||
// 비어있으면 startDate는 필수, endDate는 null
|
||||
if (normalizedHeader == 'startDate') {
|
||||
if (header == 'startDate') {
|
||||
event['startDate'] = value.isEmpty ? DateTime.now().toIso8601String() : value;
|
||||
} else {
|
||||
event['endDate'] = value.isEmpty ? null : value;
|
||||
}
|
||||
} else {
|
||||
event[normalizedHeader] = value.isEmpty ? null : value;
|
||||
event[header] = value.isEmpty ? null : value;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -310,5 +310,44 @@ class TieBreakerUtil {
|
||||
|
||||
return ranks;
|
||||
}
|
||||
|
||||
|
||||
/// 재사용 가능한: 점수 리스트에 대한 순위 계산 (외부에서 쉽게 호출)
|
||||
static Map<String, int> computeScoreRanks(
|
||||
List<Score> scores, {
|
||||
required bool includeHandicap,
|
||||
List<TieBreakerOption>? options,
|
||||
List<Member>? members,
|
||||
}) {
|
||||
return calculateRanks(
|
||||
scores,
|
||||
includeHandicap,
|
||||
options: options,
|
||||
members: members,
|
||||
);
|
||||
}
|
||||
|
||||
/// 재사용 가능한: 참가자 요약(Map) 리스트에 정렬/순위 부여
|
||||
/// - summaries: 각 항목은 최소 'total'과 'totalH' 키를 포함해야 함
|
||||
/// - includeHandicap: 어떤 합계를 정렬 기준으로 사용할지 결정
|
||||
/// - totalKey/totalHKey: 키명이 다른 경우 오버라이드 가능
|
||||
static List<Map<String, dynamic>> rankParticipantSummaries(
|
||||
List<Map<String, dynamic>> summaries, {
|
||||
required bool includeHandicap,
|
||||
String totalKey = 'total',
|
||||
String totalHKey = 'totalH',
|
||||
}) {
|
||||
final items = List<Map<String, dynamic>>.of(summaries);
|
||||
items.sort((a, b) {
|
||||
final at = includeHandicap ? (a[totalHKey] as int) : (a[totalKey] as int);
|
||||
final bt = includeHandicap ? (b[totalHKey] as int) : (b[totalKey] as int);
|
||||
return bt.compareTo(at); // desc
|
||||
});
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
items[i] = {
|
||||
...items[i],
|
||||
'rank': i + 1,
|
||||
};
|
||||
}
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ import 'test_utils.dart';
|
||||
|
||||
/// URL 관련 유틸리티 기능을 제공하는 클래스
|
||||
class UrlUtils {
|
||||
/// 이벤트 공개 URL의 기본 도메인
|
||||
static const String eventBaseUrl = 'https://bowling.example.com/events/';
|
||||
/// 이벤트 공개 URL의 기본 도메인 (프로덕션)
|
||||
static const String eventBaseUrl = 'https://lanebow.com/events/';
|
||||
|
||||
/// 공개 URL 해시 생성
|
||||
///
|
||||
|
||||
Reference in New Issue
Block a user