diff --git a/backend/app.js b/backend/app.js
index ee1ca6b..9cbd26c 100644
--- a/backend/app.js
+++ b/backend/app.js
@@ -34,7 +34,7 @@ function getDomain(origin) {
}
}
-const allowedDomains = ['lanebow.com', 'localhost'];
+const allowedDomains = ['lanebow.com', 'www.lanebow.com', 'localhost'];
app.use(cors({
origin: function(origin, callback) {
@@ -159,18 +159,17 @@ app.use('/api/users', userRoutes);
app.use('/api/notifications', notificationRoutes);
app.use('/api/menus', menuRoutes);
-// 정적 파일 제공 설정
+// 정적 파일 제공 설정 (Flutter Web 빌드 산출물)
const path = require('path');
-app.use(express.static(path.join(__dirname, '../frontend/dist')));
+const WEB_ROOT = process.env.FRONTEND_ROOT || path.join(__dirname, '../public');
+app.use(express.static(WEB_ROOT));
-// API 요청이 아닌 모든 요청은 Vue 앱으로 라우팅
+// API 요청이 아닌 모든 요청은 Flutter Web 앱으로 라우팅 (SPA Fallback)
app.get('*', (req, res, next) => {
- // API 요청은 무시하고 다음 미들웨어로 전달
if (req.path.startsWith('/api/')) {
return next();
}
- // Vue 앱의 index.html 반환
- res.sendFile(path.join(__dirname, '../frontend/dist/index.html'));
+ res.sendFile(path.join(WEB_ROOT, 'index.html'));
});
// 서버 시작
diff --git a/mobile/ios/Flutter/AppFrameworkInfo.plist b/mobile/ios/Flutter/AppFrameworkInfo.plist
index 7c56964..1dc6cf7 100644
--- a/mobile/ios/Flutter/AppFrameworkInfo.plist
+++ b/mobile/ios/Flutter/AppFrameworkInfo.plist
@@ -21,6 +21,6 @@
CFBundleVersion
1.0
MinimumOSVersion
- 12.0
+ 13.0
diff --git a/mobile/ios/Podfile b/mobile/ios/Podfile
index e549ee2..620e46e 100644
--- a/mobile/ios/Podfile
+++ b/mobile/ios/Podfile
@@ -1,5 +1,5 @@
# Uncomment this line to define a global platform for your project
-# platform :ios, '12.0'
+# platform :ios, '13.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock
index dc2d326..ec787a8 100644
--- a/mobile/ios/Podfile.lock
+++ b/mobile/ios/Podfile.lock
@@ -98,17 +98,17 @@ SPEC CHECKSUMS:
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be
- Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
+ Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_local_notifications: a5a732f069baa862e728d839dd2ebb904737effb
flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
in_app_purchase_storekit: d1a48cb0f8b29dbf5f85f782f5dd79b21b90a5e6
integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e
- path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
+ path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
SDWebImage: f29024626962457f3470184232766516dee8dfea
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
- shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
+ shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
-PODFILE CHECKSUM: 4305caec6b40dde0ae97be1573c53de1882a07e5
+PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e
COCOAPODS: 1.16.2
diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj
index 5fcef8a..a4a16d8 100644
--- a/mobile/ios/Runner.xcodeproj/project.pbxproj
+++ b/mobile/ios/Runner.xcodeproj/project.pbxproj
@@ -455,7 +455,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
- IPHONEOS_DEPLOYMENT_TARGET = 12.0;
+ IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
@@ -584,7 +584,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
- IPHONEOS_DEPLOYMENT_TARGET = 12.0;
+ IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
@@ -635,7 +635,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
- IPHONEOS_DEPLOYMENT_TARGET = 12.0;
+ IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart
index 9a10e5c..7ae81e1 100644
--- a/mobile/lib/main.dart
+++ b/mobile/lib/main.dart
@@ -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 {
int _selectedIndex = 0;
bool _isInit = false;
- static final List _widgetOptions = [
- const DashboardScreen(),
- const MembersScreen(),
- const EventsScreen(),
- const ClubStatisticsScreen(),
- const ProfileScreen(),
+ List get _widgetOptions => const [
+ DashboardScreen(),
+ MembersScreen(),
+ EventsScreen(),
+ ProfileScreen(),
];
void _onItemTapped(int index) {
@@ -367,28 +366,29 @@ class _HomeScreenState extends State {
},
),
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 {
icon: Icon(Icons.event),
label: '이벤트',
),
- BottomNavigationBarItem(
- icon: Icon(Icons.score),
- label: '통계',
- ),
BottomNavigationBarItem(
icon: Icon(Icons.person),
label: '프로필',
diff --git a/mobile/lib/models/event_model.dart b/mobile/lib/models/event_model.dart
index 1647436..96d9e52 100644
--- a/mobile/lib/models/event_model.dart
+++ b/mobile/lib/models/event_model.dart
@@ -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 toJson() {
return {
diff --git a/mobile/lib/models/member_model.dart b/mobile/lib/models/member_model.dart
index 8941949..ea7cbbf 100644
--- a/mobile/lib/models/member_model.dart
+++ b/mobile/lib/models/member_model.dart
@@ -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,
diff --git a/mobile/lib/models/participant_model.dart b/mobile/lib/models/participant_model.dart
index 152d4d1..bac4b41 100644
--- a/mobile/lib/models/participant_model.dart
+++ b/mobile/lib/models/participant_model.dart
@@ -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 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,
};
}
}
diff --git a/mobile/lib/models/score_model.dart b/mobile/lib/models/score_model.dart
index b01821f..f04d4df 100644
--- a/mobile/lib/models/score_model.dart
+++ b/mobile/lib/models/score_model.dart
@@ -3,9 +3,11 @@ class Score {
final String memberId;
final String eventId;
final String clubId;
+ final String? participantId; // 참가자 ID (백엔드 EventScore 필드)
final List 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.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 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;
}
}
diff --git a/mobile/lib/screens/club/club_settings_screen.dart b/mobile/lib/screens/club/club_settings_screen.dart
index bcd632d..992a22b 100644
--- a/mobile/lib/screens/club/club_settings_screen.dart
+++ b/mobile/lib/screens/club/club_settings_screen.dart
@@ -44,7 +44,8 @@ class _ClubSettingsScreenState extends State {
_isLoading = false;
_hasEditPermission = true; // 테스트에서는 편집 가능 상태로 둠
} else {
- _loadData();
+ // 첫 프레임 이후에 로드하여 InheritedWidget 접근 안전 보장
+ WidgetsBinding.instance.addPostFrameCallback((_) => _loadData());
}
}
@@ -62,8 +63,6 @@ class _ClubSettingsScreenState extends State {
_isLoading = true;
});
- // await 이전에 messenger 캡처 (catch에서 사용)
- final messenger = ScaffoldMessenger.of(context);
// await 이전에 authService 캡처 (이후 권한 계산에 사용)
final authService = Provider.of(context, listen: false);
@@ -115,7 +114,8 @@ class _ClubSettingsScreenState extends State {
});
}
} catch (error) {
- messenger.showSnackBar(
+ if (!mounted) return;
+ ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('데이터 로드 중 오류가 발생했습니다: $error')),
);
} finally {
diff --git a/mobile/lib/screens/club/event_details_screen.dart b/mobile/lib/screens/club/event_details_screen.dart
index 525f24e..709ba78 100644
--- a/mobile/lib/screens/club/event_details_screen.dart
+++ b/mobile/lib/screens/club/event_details_screen.dart
@@ -12,34 +12,38 @@ import '../../models/member_model.dart';
import '../../models/participant_model.dart';
import '../../models/score_model.dart';
import '../../models/team_model.dart';
-import '../../models/tie_breaker_option.dart';
import '../../services/club_service.dart';
import '../../services/event_service.dart';
import '../../services/member_service.dart';
import '../../services/notification_service.dart';
-import '../../utils/tie_breaker_util.dart';
import '../../utils/url_utils.dart';
import '../../widgets/loading_indicator.dart';
import '../../utils/enum_mappings.dart';
-import 'participant_form_screen.dart';
+// participant_form_screen 제거: 모든 추가는 기존 회원 빠른추가 경로로 통일
import 'score_form_screen.dart';
-import 'team_generator_screen.dart';
-import '../../theme/badges.dart';
+import 'event_form_screen.dart';
+import '../score/score_batch_edit_screen.dart';
+import 'tabs/scores_tab.dart';
+import 'tabs/basic_info_tab.dart';
+import 'tabs/participants_tab.dart';
+import 'tabs/teams_tab.dart';
class EventDetailsScreen extends StatefulWidget {
final Event event;
+ // 테스트 시 초기 비동기 로드를 건너뛰기 위한 플래그
+ @visibleForTesting
+ static bool testMode = false;
- const EventDetailsScreen({
- Key? key,
- required this.event,
- }) : super(key: key);
+ const EventDetailsScreen({Key? key, required this.event}) : super(key: key);
@override
State createState() => _EventDetailsScreenState();
}
-class _EventDetailsScreenState extends State with SingleTickerProviderStateMixin {
+class _EventDetailsScreenState extends State
+ with SingleTickerProviderStateMixin {
late TabController _tabController;
+ Event? _event; // 최신 이벤트 정보 보관
List _participants = [];
List _scores = [];
List _teams = [];
@@ -49,20 +53,16 @@ class _EventDetailsScreenState extends State with SingleTick
Club? _club;
bool _isLoading = true;
int _teamGenerationMethod = 1; // 1: 팀 인원수로 나누기, 2: 팀 개수로 나누기
- final TextEditingController _teamSizeController = TextEditingController(text: '4');
- final TextEditingController _teamCountController = TextEditingController(text: '2');
+ final TextEditingController _teamSizeController = TextEditingController(
+ text: '4',
+ );
+ final TextEditingController _teamCountController = TextEditingController(
+ text: '2',
+ );
bool _showTeamTab = false;
bool _includeHandicap = true; // 핸디캡 포함 여부
- // 참가자 필터/검색 상태
- String? _participantFilterMemberType; // null: 전체
- String? _participantFilterStatus; // null: 전체
- bool? _participantFilterPaid; // null: 전체, true: 결제완료, false: 미결제
- final TextEditingController _participantSearchController = TextEditingController();
- // 참가자 빠른추가 상태
- String? _quickAddSelectedMemberId;
- bool _quickAddLoading = false;
+ bool _groupByParticipant = false; // 점수 표시 방식: false=점수별(기존), true=참가자별 그룹
bool _clubMembersLoading = false;
- String? _clubMembersError;
VoidCallback? _memberServiceListener;
MemberService? _memberServiceRef;
@@ -88,7 +88,6 @@ class _EventDetailsScreenState extends State with SingleTick
setState(() {
_clubMembers = memberService.members;
_clubMembersLoading = memberService.isLoading;
- _clubMembersError = memberService.lastError;
});
// 리스너 등록: 회원 목록/로딩 상태 변화 시 동기화
_memberServiceListener = () {
@@ -96,15 +95,121 @@ class _EventDetailsScreenState extends State with SingleTick
setState(() {
_clubMembers = memberService.members;
_clubMembersLoading = memberService.isLoading;
- _clubMembersError = memberService.lastError;
});
};
memberService.addListener(_memberServiceListener!);
- // 이벤트 상세 로드
- _loadEventDetails();
+ // 이벤트 상세 로드 (테스트 모드에서는 스킵)
+ if (!EventDetailsScreen.testMode) {
+ _loadEventDetails();
+ } else {
+ setState(() {
+ _isLoading = false;
+ });
+ }
});
}
+ // 드래그앤드롭으로 팀 간 참가자 이동 처리
+ void _moveParticipantBetweenTeams(
+ Participant participant,
+ Team fromTeam,
+ Team toTeam,
+ int toIndex,
+ ) {
+ setState(() {
+ final fromIdx = _teams.indexWhere((t) => t.id == fromTeam.id);
+ final toIdx = _teams.indexWhere((t) => t.id == toTeam.id);
+ if (toIdx < 0) return;
+
+ final memberId = participant.memberId;
+ // remove from source (미배정에서 오는 경우 fromIdx는 -1이 될 수 있음)
+ List? fromMemberIds;
+ if (fromIdx >= 0) {
+ fromMemberIds = List.from(_teams[fromIdx].memberIds);
+ fromMemberIds.remove(memberId);
+ }
+
+ // insert into target at index
+ final toMemberIds = List.from(_teams[toIdx].memberIds);
+ // 중복 방지: 기존에 있으면 먼저 제거 (같은 팀 이동 시 길이가 줄어듦)
+ toMemberIds.remove(memberId);
+ // 제거 이후 길이에 맞춰 인덱스 보정
+ int insertIndex = toIndex.clamp(0, toMemberIds.length);
+ toMemberIds.insert(insertIndex, memberId);
+
+ if (fromIdx >= 0 && fromMemberIds != null) {
+ _teams[fromIdx] = Team(
+ id: _teams[fromIdx].id,
+ eventId: _teams[fromIdx].eventId,
+ name: _teams[fromIdx].name,
+ memberIds: fromMemberIds,
+ description: _teams[fromIdx].description,
+ createdAt: _teams[fromIdx].createdAt,
+ updatedAt: _teams[fromIdx].updatedAt,
+ );
+ }
+
+ _teams[toIdx] = Team(
+ id: _teams[toIdx].id,
+ eventId: _teams[toIdx].eventId,
+ name: _teams[toIdx].name,
+ memberIds: toMemberIds,
+ description: _teams[toIdx].description,
+ createdAt: _teams[toIdx].createdAt,
+ updatedAt: _teams[toIdx].updatedAt,
+ );
+ });
+ }
+
+
+
+ // 참가자 추가 선택 시트: 기존 회원 추가 vs 게스트 추가
+ Future _openAddChooser() async {
+ if (!mounted) return;
+ await showModalBottomSheet(
+ context: context,
+ shape: const RoundedRectangleBorder(
+ borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
+ ),
+ builder: (ctx) {
+ return SafeArea(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ ListTile(
+ key: const Key('add_existing_member'),
+ leading: const Icon(Icons.person_add_alt_1),
+ title: const Text('기존 회원 추가'),
+ onTap: () async {
+ Navigator.of(ctx).pop();
+ await _openMemberQuickAddModal();
+ },
+ ),
+ ListTile(
+ key: const Key('add_guest'),
+ leading: const Icon(Icons.group_add),
+ title: const Text('게스트 추가'),
+ onTap: () async {
+ Navigator.of(ctx).pop();
+ await _openGuestModal();
+ },
+ ),
+ const SizedBox(height: 8),
+ ],
+ ),
+ );
+ },
+ );
+ }
+
+ // 테스트 전용: 탭 전환 없이 모달을 직접 오픈
+ @visibleForTesting
+ Future openQuickAddForTest() => _openMemberQuickAddModal();
+
+ // 테스트 전용: 탭 전환/ FAB 없이 추가 선택 시트를 직접 오픈
+ @visibleForTesting
+ Future openAddChooserForTest() => _openAddChooser();
+
Future _duplicateEvent() async {
final messenger = ScaffoldMessenger.of(context);
try {
@@ -121,6 +226,93 @@ class _EventDetailsScreenState extends State with SingleTick
}
}
+ @visibleForTesting
+ List