이벤트 가져오기 위젯 테스트 추가 및 테스트 훅 도입
This commit is contained in:
@@ -1,6 +1,10 @@
|
||||
class ApiConfig {
|
||||
// 백엔드 API 기본 URL
|
||||
static const String baseUrl = 'https://lanebow.com/api';
|
||||
// --dart-define=API_BASE_URL=... 로 오버라이드 가능. 기본값은 프로덕션.
|
||||
static String get baseUrl => const String.fromEnvironment(
|
||||
'API_BASE_URL',
|
||||
defaultValue: 'https://lanebow.com/api',
|
||||
);
|
||||
|
||||
// API 엔드포인트
|
||||
static const String login = '/auth/login';
|
||||
|
||||
+125
-60
@@ -16,6 +16,7 @@ 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 'widgets/dialog_actions.dart';
|
||||
|
||||
// 전역 네비게이터 키 (인증 오류 처리용)
|
||||
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
|
||||
@@ -58,10 +59,82 @@ class MyApp extends StatelessWidget {
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
|
||||
useMaterial3: true,
|
||||
visualDensity: VisualDensity.adaptivePlatformDensity,
|
||||
appBarTheme: const AppBarTheme(
|
||||
backgroundColor: Colors.blue,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
chipTheme: ChipThemeData(
|
||||
shape: StadiumBorder(side: BorderSide.none),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 0),
|
||||
),
|
||||
dialogTheme: const DialogThemeData(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(16))),
|
||||
titleTextStyle: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.black87),
|
||||
contentTextStyle: TextStyle(fontSize: 14, color: Colors.black87),
|
||||
),
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
shape: const StadiumBorder(),
|
||||
),
|
||||
),
|
||||
textButtonTheme: TextButtonThemeData(
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
),
|
||||
),
|
||||
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
shape: const StadiumBorder(),
|
||||
),
|
||||
),
|
||||
cardTheme: const CardThemeData(
|
||||
elevation: 1.5,
|
||||
margin: EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))),
|
||||
),
|
||||
inputDecorationTheme: const InputDecorationTheme(
|
||||
border: OutlineInputBorder(),
|
||||
isDense: true,
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
||||
),
|
||||
snackBarTheme: const SnackBarThemeData(
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(8))),
|
||||
actionTextColor: Colors.white,
|
||||
),
|
||||
),
|
||||
darkTheme: ThemeData(
|
||||
brightness: Brightness.dark,
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue, brightness: Brightness.dark),
|
||||
useMaterial3: true,
|
||||
visualDensity: VisualDensity.adaptivePlatformDensity,
|
||||
textTheme: const TextTheme(
|
||||
bodyMedium: TextStyle(color: Colors.white70),
|
||||
bodySmall: TextStyle(color: Colors.white60),
|
||||
labelMedium: TextStyle(color: Colors.white70),
|
||||
),
|
||||
iconTheme: IconThemeData(
|
||||
color: Colors.grey.shade200, // 다크에서 아이콘 대비 상향
|
||||
),
|
||||
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
shape: const StadiumBorder(),
|
||||
side: BorderSide(color: Colors.grey.shade400), // 다크에서 border 대비 강화
|
||||
),
|
||||
),
|
||||
cardTheme: const CardThemeData(
|
||||
elevation: 0.5, // 다크에서 쉐도우 약화
|
||||
margin: EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))),
|
||||
),
|
||||
snackBarTheme: const SnackBarThemeData(
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(8))),
|
||||
),
|
||||
),
|
||||
// 로케일 설정 추가
|
||||
localizationsDelegates: const [
|
||||
@@ -112,17 +185,24 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
});
|
||||
}
|
||||
|
||||
// 클럽 선택 다이얼로그 표시
|
||||
Future<void> _showClubSelectionDialog(BuildContext context) async {
|
||||
final clubService = Provider.of<ClubService>(context, listen: false);
|
||||
final authService = Provider.of<AuthService>(context, listen: false);
|
||||
// 클럽 선택 다이얼로그 표시 (context를 인자로 받지 않음)
|
||||
Future<void> _showClubSelectionDialog() async {
|
||||
// 현재 시점의 컨텍스트를 통해 의존 객체를 가져오되, BuildContext 자체는 보관하지 않는다.
|
||||
final currentContext = navigatorKey.currentContext!;
|
||||
final clubService = Provider.of<ClubService>(currentContext, listen: false);
|
||||
final authService = Provider.of<AuthService>(currentContext, listen: false);
|
||||
final messenger = ScaffoldMessenger.of(currentContext);
|
||||
// onTap 내부에서 사용할 서비스들을 await 이전에 캡처
|
||||
final memberService = Provider.of<MemberService>(currentContext, listen: false);
|
||||
final eventService = Provider.of<EventService>(currentContext, listen: false);
|
||||
final scoreService = Provider.of<ScoreService>(currentContext, listen: false);
|
||||
|
||||
// 사용자의 모든 클럽 가져오기
|
||||
if (clubService.clubs.isEmpty) {
|
||||
try {
|
||||
await clubService.fetchUserClubs();
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('클럽 목록을 불러오는데 실패했습니다: $e')),
|
||||
);
|
||||
return;
|
||||
@@ -130,18 +210,16 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
}
|
||||
|
||||
if (clubService.clubs.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(content: Text('소속된 클럽이 없습니다')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
// 다이얼로그 표시
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
context: navigatorKey.currentContext!,
|
||||
builder: (BuildContext dialogContext) {
|
||||
return AlertDialog(
|
||||
title: const Text('클럽 선택'),
|
||||
content: SizedBox(
|
||||
@@ -158,7 +236,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
subtitle: Text(club.description ?? ''),
|
||||
trailing: isSelected ? const Icon(Icons.check, color: Colors.blue) : null,
|
||||
onTap: () async {
|
||||
Navigator.of(context).pop();
|
||||
Navigator.of(dialogContext).pop();
|
||||
|
||||
if (!isSelected) {
|
||||
try {
|
||||
@@ -166,9 +244,6 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
await clubService.selectClub(club.id);
|
||||
|
||||
// 관련 서비스 초기화
|
||||
final memberService = Provider.of<MemberService>(context, listen: false);
|
||||
final eventService = Provider.of<EventService>(context, listen: false);
|
||||
final scoreService = Provider.of<ScoreService>(context, listen: false);
|
||||
|
||||
memberService.initialize(authService.token!);
|
||||
eventService.initialize(authService.token!);
|
||||
@@ -180,17 +255,13 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
eventService.fetchClubEvents(),
|
||||
]);
|
||||
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('${club.name} 클럽으로 변경되었습니다')),
|
||||
);
|
||||
}
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('${club.name} 클럽으로 변경되었습니다')),
|
||||
);
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('클럽 변경 중 오류가 발생했습니다: $e')),
|
||||
);
|
||||
}
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('클럽 변경 중 오류가 발생했습니다: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -198,12 +269,11 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
},
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
],
|
||||
actions: DialogActions.confirm(
|
||||
onCancel: () => Navigator.of(dialogContext).pop(),
|
||||
onConfirm: () => Navigator.of(dialogContext).pop(),
|
||||
confirmText: '닫기',
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -219,6 +289,8 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
}
|
||||
|
||||
Future<void> _initializeServices() async {
|
||||
// messenger는 try/catch 전역에서 재사용 (await 이후 context 접근 회피)
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
try {
|
||||
final authService = Provider.of<AuthService>(context, listen: false);
|
||||
final clubService = Provider.of<ClubService>(context, listen: false);
|
||||
@@ -242,42 +314,33 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
await clubService.selectClub(clubService.clubs[0].id);
|
||||
scoreService.initialize(authService.token!);
|
||||
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('${clubService.clubs[0].name} 클럽이 자동으로 선택되었습니다')),
|
||||
);
|
||||
}
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('${clubService.clubs[0].name} 클럽이 자동으로 선택되었습니다')),
|
||||
);
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('클럽 자동 선택 중 오류가 발생했습니다: $e')),
|
||||
);
|
||||
}
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('클럽 자동 선택 중 오류가 발생했습니다: $e')),
|
||||
);
|
||||
}
|
||||
} else if (clubService.clubs.isNotEmpty) {
|
||||
// 클럽이 여러 개 있으면 선택 다이얼로그 표시 (타이머 생성 회피: microtask 사용)
|
||||
if (!mounted) return;
|
||||
Future.microtask(() {
|
||||
if (context.mounted) {
|
||||
_showClubSelectionDialog(context);
|
||||
}
|
||||
_showClubSelectionDialog();
|
||||
});
|
||||
} else {
|
||||
// 클럽이 없는 경우 메시지 표시
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('소속된 클럽이 없습니다. 클럽을 생성하거나 초대를 요청하세요.')),
|
||||
);
|
||||
}
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(content: Text('소속된 클럽이 없습니다. 클럽을 생성하거나 초대를 요청하세요.')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 오류 처리
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('서비스 초기화 중 오류가 발생했습니다: $e')),
|
||||
);
|
||||
}
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('서비스 초기화 중 오류가 발생했습니다: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,7 +351,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
title: Consumer<ClubService>(
|
||||
builder: (context, clubService, child) {
|
||||
return InkWell(
|
||||
onTap: () => _showClubSelectionDialog(context),
|
||||
onTap: () => _showClubSelectionDialog(),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
@@ -309,15 +372,17 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
onPressed: () async {
|
||||
// 알림 권한 요청
|
||||
final notificationService = NotificationService();
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final hasPermission = await notificationService.requestPermission();
|
||||
|
||||
if (context.mounted) {
|
||||
if (hasPermission) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
if (hasPermission) {
|
||||
if (context.mounted) {
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(content: Text('알림 권한이 허용되었습니다')),
|
||||
);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
}
|
||||
} else {
|
||||
if (context.mounted) {
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(content: Text('알림 권한이 거부되었습니다. 설정에서 권한을 허용해주세요')),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -42,15 +42,17 @@ class Participant {
|
||||
id: json['id']?.toString() ?? '',
|
||||
eventId: json['eventId']?.toString() ?? '',
|
||||
memberId: json['memberId']?.toString() ?? '',
|
||||
name: json['name']?.toString(),
|
||||
email: json['email']?.toString(),
|
||||
phoneNumber: json['phoneNumber']?.toString(),
|
||||
// 서버 응답에 Member가 중첩되는 경우 보정
|
||||
name: (json['name'] ?? json['Member']?['name'])?.toString(),
|
||||
email: (json['email'] ?? json['Member']?['email'])?.toString(),
|
||||
phoneNumber: (json['phoneNumber'] ?? json['phone'] ?? json['Member']?['phone'])?.toString(),
|
||||
status: json['status']?.toString(),
|
||||
registeredAt: json['registeredAt'] != null ? DateTime.parse(json['registeredAt'].toString()) : null,
|
||||
confirmedAt: json['confirmedAt'] != null ? DateTime.parse(json['confirmedAt'].toString()) : null,
|
||||
cancelledAt: json['cancelledAt'] != null ? DateTime.parse(json['cancelledAt'].toString()) : null,
|
||||
attendedAt: json['attendedAt'] != null ? DateTime.parse(json['attendedAt'].toString()) : null,
|
||||
isPaid: json['isPaid'] ?? false,
|
||||
// 서버는 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(),
|
||||
createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt'].toString()) : null,
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
|
||||
import '../../services/admin_service.dart';
|
||||
import '../../services/subscription_service.dart';
|
||||
import '../../models/subscription_model.dart';
|
||||
import '../../widgets/dialog_actions.dart';
|
||||
|
||||
/// 관리자 대시보드 화면
|
||||
class AdminDashboardScreen extends StatefulWidget {
|
||||
@@ -32,18 +33,21 @@ class _AdminDashboardScreenState extends State<AdminDashboardScreen> {
|
||||
try {
|
||||
final adminService = Provider.of<AdminService>(context, listen: false);
|
||||
final analyticsData = await adminService.fetchClubAnalytics();
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_analyticsData = analyticsData;
|
||||
});
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('데이터 로드 중 오류가 발생했습니다: $e')),
|
||||
);
|
||||
} finally {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -374,19 +378,20 @@ class _AdminDashboardScreenState extends State<AdminDashboardScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
],
|
||||
actions: DialogActions.confirm(
|
||||
onCancel: () => Navigator.of(context).pop(),
|
||||
onConfirm: () => Navigator.of(context).pop(),
|
||||
confirmText: '닫기',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 데이터 내보내기 처리
|
||||
Future<void> _exportData(BuildContext context, String dataType) async {
|
||||
Navigator.of(context).pop(); // 다이얼로그 닫기
|
||||
final navigator = Navigator.of(context);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
navigator.pop(); // 다이얼로그 닫기
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
@@ -397,22 +402,24 @@ class _AdminDashboardScreenState extends State<AdminDashboardScreen> {
|
||||
final downloadUrl = await adminService.exportClubData(dataType);
|
||||
|
||||
if (downloadUrl != null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('데이터 내보내기 성공: $downloadUrl')),
|
||||
);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(content: Text('데이터 내보내기에 실패했습니다.')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('데이터 내보내기 중 오류가 발생했습니다: $e')),
|
||||
);
|
||||
} finally {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _emailController = TextEditingController();
|
||||
bool _isSubmitting = false;
|
||||
bool _emailSent = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
@@ -35,7 +34,6 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
|
||||
|
||||
setState(() {
|
||||
_isSubmitting = false;
|
||||
_emailSent = success;
|
||||
});
|
||||
|
||||
if (success && mounted) {
|
||||
|
||||
@@ -6,14 +6,20 @@ import '../../services/auth_service.dart';
|
||||
import '../../services/club_service.dart';
|
||||
import '../../services/member_service.dart';
|
||||
import '../../utils/enum_mappings.dart';
|
||||
import '../../widgets/owner_dropdown.dart';
|
||||
|
||||
class ClubSettingsScreen extends StatefulWidget {
|
||||
static const routeName = '/club-settings';
|
||||
|
||||
const ClubSettingsScreen({super.key});
|
||||
const ClubSettingsScreen({super.key, this.onOwnerItemsBuilt, this.initialMembers});
|
||||
|
||||
// 테스트 전용: 모임장 드롭다운 항목 개수 리포트 콜백
|
||||
final ValueChanged<int>? onOwnerItemsBuilt;
|
||||
// 테스트 전용: 초기 멤버 목록을 직접 주입하여 로딩을 우회
|
||||
final List<Member>? initialMembers;
|
||||
|
||||
@override
|
||||
_ClubSettingsScreenState createState() => _ClubSettingsScreenState();
|
||||
State<ClubSettingsScreen> createState() => _ClubSettingsScreenState();
|
||||
}
|
||||
|
||||
class _ClubSettingsScreenState extends State<ClubSettingsScreen> {
|
||||
@@ -32,7 +38,14 @@ class _ClubSettingsScreenState extends State<ClubSettingsScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadData();
|
||||
if (widget.initialMembers != null) {
|
||||
// 테스트 경로: 초기 데이터 주입
|
||||
_members = List<Member>.from(widget.initialMembers!);
|
||||
_isLoading = false;
|
||||
_hasEditPermission = true; // 테스트에서는 편집 가능 상태로 둠
|
||||
} else {
|
||||
_loadData();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -49,6 +62,11 @@ 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);
|
||||
|
||||
try {
|
||||
// 클럽 정보 로드
|
||||
final clubService = Provider.of<ClubService>(context, listen: false);
|
||||
@@ -72,7 +90,6 @@ class _ClubSettingsScreenState extends State<ClubSettingsScreen> {
|
||||
});
|
||||
|
||||
// 권한 확인
|
||||
final authService = Provider.of<AuthService>(context, listen: false);
|
||||
final currentUserId = authService.currentUser?.id;
|
||||
|
||||
// 현재 사용자가 클럽 소유자이거나 관리자인지 확인
|
||||
@@ -98,9 +115,9 @@ class _ClubSettingsScreenState extends State<ClubSettingsScreen> {
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('데이터 로드 중 오류가 발생했습니다: $error')));
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('데이터 로드 중 오류가 발생했습니다: $error')),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
@@ -119,6 +136,9 @@ class _ClubSettingsScreenState extends State<ClubSettingsScreen> {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
// await 이전에 messenger 캡처
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
|
||||
try {
|
||||
final clubService = Provider.of<ClubService>(context, listen: false);
|
||||
final club = clubService.currentClub;
|
||||
@@ -185,18 +205,14 @@ class _ClubSettingsScreenState extends State<ClubSettingsScreen> {
|
||||
|
||||
await clubService.updateClub(club.id, updates);
|
||||
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('클럽 정보가 성공적으로 저장되었습니다')));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('클럽 정보 저장 중 오류가 발생했습니다: $error')),
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(content: Text('클럽 정보가 성공적으로 저장되었습니다')),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('클럽 정보 저장 중 오류가 발생했습니다: $error')),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
@@ -294,16 +310,22 @@ class _ClubSettingsScreenState extends State<ClubSettingsScreen> {
|
||||
// 평균 산정 기준
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<String>(
|
||||
key: const Key('club_settings_avg_period_dropdown'),
|
||||
decoration: const InputDecoration(
|
||||
labelText: '평균 산정 기준',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
value: _selectedAverageCalculationPeriod,
|
||||
isExpanded: true,
|
||||
items: averageCalculationPeriodOptions.entries
|
||||
.map(
|
||||
(entry) => DropdownMenuItem<String>(
|
||||
value: entry.key,
|
||||
child: Text(entry.value),
|
||||
child: Text(
|
||||
entry.value,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
@@ -317,27 +339,18 @@ class _ClubSettingsScreenState extends State<ClubSettingsScreen> {
|
||||
),
|
||||
// 모임장 설정
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<String>(
|
||||
decoration: const InputDecoration(
|
||||
labelText: '모임장 설정',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
OwnerDropdown(
|
||||
key: const Key('owner_dropdown'),
|
||||
members: _members,
|
||||
value: _selectedOwnerId,
|
||||
items: _members.map((member) {
|
||||
return DropdownMenuItem<String>(
|
||||
value: member.userId,
|
||||
child: Text(
|
||||
'${member.name} (${getMemberTypeLabel(member.memberType)})',
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: _hasEditPermission
|
||||
? (value) {
|
||||
setState(() {
|
||||
_selectedOwnerId = value;
|
||||
});
|
||||
}
|
||||
: null,
|
||||
enabled: _hasEditPermission,
|
||||
onChanged: (value) {
|
||||
if (!_hasEditPermission) return;
|
||||
setState(() {
|
||||
_selectedOwnerId = value;
|
||||
});
|
||||
},
|
||||
onItemsBuilt: widget.onOwnerItemsBuilt,
|
||||
),
|
||||
// 저장 버튼
|
||||
const SizedBox(height: 24),
|
||||
|
||||
@@ -8,6 +8,7 @@ import '../../services/member_service.dart';
|
||||
import '../../services/event_service.dart';
|
||||
import '../../models/club_model.dart';
|
||||
import 'club_settings_screen.dart';
|
||||
import '../../theme/badges.dart';
|
||||
|
||||
class DashboardScreen extends StatefulWidget {
|
||||
const DashboardScreen({super.key});
|
||||
@@ -34,10 +35,15 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
// await 이전에 messenger 캡처 (catch에서 사용)
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
// await 이전에 서비스 인스턴스 캡처
|
||||
final authService = Provider.of<AuthService>(context, listen: false);
|
||||
final clubService = Provider.of<ClubService>(context, listen: false);
|
||||
final memberService = Provider.of<MemberService>(context, listen: false);
|
||||
final eventService = Provider.of<EventService>(context, listen: false);
|
||||
|
||||
try {
|
||||
final authService = Provider.of<AuthService>(context, listen: false);
|
||||
final clubService = Provider.of<ClubService>(context, listen: false);
|
||||
|
||||
// 클럽 정보 로드
|
||||
if (clubService.currentClub == null) {
|
||||
await clubService.initialize(authService.token!);
|
||||
@@ -45,9 +51,6 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
||||
|
||||
// 회원 및 이벤트 서비스 초기화
|
||||
if (clubService.currentClub != null) {
|
||||
final memberService = Provider.of<MemberService>(context, listen: false);
|
||||
final eventService = Provider.of<EventService>(context, listen: false);
|
||||
|
||||
memberService.initialize(authService.token!);
|
||||
eventService.initialize(authService.token!);
|
||||
|
||||
@@ -59,13 +62,15 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
||||
}
|
||||
} catch (e) {
|
||||
// 오류 처리
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('데이터 로드 중 오류가 발생했습니다: $e')),
|
||||
);
|
||||
} finally {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,11 +369,23 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 4),
|
||||
if (event.status != null) ...[
|
||||
Wrap(
|
||||
spacing: 4,
|
||||
runSpacing: 4,
|
||||
children: [
|
||||
BadgeStyles.eventStatus(event.status!),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
Text(
|
||||
dateFormat.format(event.startDate),
|
||||
style: TextStyle(
|
||||
@@ -381,6 +398,8 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
||||
style: TextStyle(
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,6 @@ import '../../utils/url_utils.dart';
|
||||
import '../../utils/test_utils.dart';
|
||||
|
||||
import '../../models/event_model.dart';
|
||||
import '../../services/auth_service.dart';
|
||||
import '../../services/event_service.dart';
|
||||
import '../../widgets/loading_indicator.dart';
|
||||
|
||||
@@ -26,8 +25,8 @@ class EventFormScreen extends StatefulWidget {
|
||||
class _EventFormScreenState extends State<EventFormScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
bool _isLoading = false;
|
||||
bool _isInit = false;
|
||||
bool _obscurePassword = true; // 비밀번호 숨김 상태 관리
|
||||
bool _testSaveMode = false; // 테스트 훅에서 저장 시 네비/스낵바 우회
|
||||
final _currencyFormat = NumberFormat.currency(locale: 'ko_KR', symbol: '₩', decimalDigits: 0);
|
||||
|
||||
// 폼 필드 컨트롤러
|
||||
@@ -114,6 +113,13 @@ class _EventFormScreenState extends State<EventFormScreen> {
|
||||
// 새 이벤트 생성 시 기본값 설정
|
||||
_type = _typeOptions[0];
|
||||
_status = _statusOptions[0];
|
||||
// 공개 해시를 동기로 초기화하여 초기 렌더 타이밍 변동을 줄임
|
||||
if (_publicHashController.text.isEmpty && !_isHashGenerated) {
|
||||
_isHashGenerated = true;
|
||||
final hash = UrlUtils.generatePublicHash();
|
||||
_publicHashController.text = hash;
|
||||
// 스낵바는 테스트에서 불필요하므로 생략, 일반 환경에서도 최초 자동 생성 시에는 표시하지 않음
|
||||
}
|
||||
}
|
||||
|
||||
// 참가비 입력 컨트롤러에 리스너 추가
|
||||
@@ -122,21 +128,7 @@ class _EventFormScreenState extends State<EventFormScreen> {
|
||||
|
||||
bool _isHashGenerated = false;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
|
||||
// 새 이벤트 생성 시에만 해시 자동 생성 (한 번만 실행되도록 플래그 사용)
|
||||
if (widget.event == null && _publicHashController.text.isEmpty && !_isHashGenerated) {
|
||||
_isHashGenerated = true;
|
||||
// 다음 프레임에서 해시 생성 (context 사용 문제 방지)
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
_generatePublicHash();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
// didChangeDependencies 사용 시 프레임 콜백에 의한 타이밍 변동이 생겨 테스트 플래키를 유발할 수 있어 제거
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
@@ -238,7 +230,6 @@ class _EventFormScreenState extends State<EventFormScreen> {
|
||||
|
||||
try {
|
||||
final eventService = Provider.of<EventService>(context, listen: false);
|
||||
final authService = Provider.of<AuthService>(context, listen: false);
|
||||
|
||||
// 이벤트 데이터 준비 - null 값 처리 개선
|
||||
final eventData = {
|
||||
@@ -271,47 +262,44 @@ class _EventFormScreenState extends State<EventFormScreen> {
|
||||
if (widget.event == null) {
|
||||
// 새 이벤트 생성
|
||||
await eventService.createEvent(eventData);
|
||||
if (mounted) {
|
||||
// 테스트 환경에서 안전하게 SnackBar 표시
|
||||
if (TestUtils.isInTest) {
|
||||
debugPrint('이벤트가 생성되었습니다');
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('이벤트가 생성되었습니다'),
|
||||
action: SnackBarAction(
|
||||
label: '확인',
|
||||
onPressed: () {},
|
||||
),
|
||||
duration: const Duration(seconds: 3),
|
||||
if (mounted && !_testSaveMode) {
|
||||
// 테스트가 아니면 스낵바 표시
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('이벤트가 생성되었습니다'),
|
||||
action: SnackBarAction(
|
||||
label: '확인',
|
||||
onPressed: () {},
|
||||
),
|
||||
);
|
||||
}
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// 기존 이벤트 수정
|
||||
await eventService.updateEvent(widget.event!.id, eventData);
|
||||
if (mounted) {
|
||||
// 테스트 환경에서 안전하게 SnackBar 표시
|
||||
if (TestUtils.isInTest) {
|
||||
debugPrint('이벤트가 수정되었습니다');
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('이벤트가 수정되었습니다'),
|
||||
action: SnackBarAction(
|
||||
label: '확인',
|
||||
onPressed: () {},
|
||||
),
|
||||
duration: const Duration(seconds: 3),
|
||||
if (mounted && !_testSaveMode) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('이벤트가 수정되었습니다'),
|
||||
action: SnackBarAction(
|
||||
label: '확인',
|
||||
onPressed: () {},
|
||||
),
|
||||
);
|
||||
}
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop(true); // 성공 결과와 함께 이전 화면으로 돌아가기
|
||||
// 성공 후 로딩 해제 (테스트/일반 공통)
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
if (!_testSaveMode) {
|
||||
Navigator.of(context).pop(true); // 성공 결과와 함께 이전 화면으로 돌아가기
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
@@ -331,6 +319,17 @@ class _EventFormScreenState extends State<EventFormScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
// 테스트 편의를 위한 공개 훅: 버튼 탭 없이 저장 플로우 직접 호출
|
||||
@visibleForTesting
|
||||
Future<void> invokeSaveForTest() async {
|
||||
_testSaveMode = true;
|
||||
try {
|
||||
await _saveEvent();
|
||||
} finally {
|
||||
_testSaveMode = false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 테스트 환경에서 애니메이션 비활성화
|
||||
@@ -427,6 +426,7 @@ class _EventFormScreenState extends State<EventFormScreen> {
|
||||
|
||||
// 이벤트 유형
|
||||
DropdownButtonFormField<String>(
|
||||
key: const Key('event_form_type_dropdown'),
|
||||
value: _type,
|
||||
decoration: InputDecoration(
|
||||
labelText: '이벤트 유형 *',
|
||||
@@ -463,6 +463,7 @@ class _EventFormScreenState extends State<EventFormScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
isExpanded: true,
|
||||
items: _typeOptions
|
||||
.map((type) => DropdownMenuItem<String>(
|
||||
value: type,
|
||||
@@ -477,13 +478,15 @@ class _EventFormScreenState extends State<EventFormScreen> {
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: _typeColors[type]?.withOpacity(0.2),
|
||||
color: _typeColors[type]?.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: _typeColors[type] ?? Colors.grey),
|
||||
),
|
||||
child: Text(
|
||||
type,
|
||||
style: TextStyle(color: _typeColors[type]),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -510,12 +513,12 @@ class _EventFormScreenState extends State<EventFormScreen> {
|
||||
margin: const EdgeInsets.only(left: 12),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: _typeColors[_type]?.withOpacity(0.2),
|
||||
color: _typeColors[_type]?.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: _typeColors[_type] ?? Colors.grey),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
blurRadius: 2,
|
||||
offset: const Offset(0, 1),
|
||||
),
|
||||
@@ -548,6 +551,7 @@ class _EventFormScreenState extends State<EventFormScreen> {
|
||||
|
||||
// 상태
|
||||
DropdownButtonFormField<String>(
|
||||
key: const Key('event_form_status_dropdown'),
|
||||
value: _status,
|
||||
decoration: InputDecoration(
|
||||
labelText: '상태 *',
|
||||
@@ -584,6 +588,7 @@ class _EventFormScreenState extends State<EventFormScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
isExpanded: true,
|
||||
items: _statusOptions
|
||||
.map((status) => DropdownMenuItem<String>(
|
||||
value: status,
|
||||
@@ -598,13 +603,15 @@ class _EventFormScreenState extends State<EventFormScreen> {
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: _statusColors[status]?.withOpacity(0.2),
|
||||
color: _statusColors[status]?.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: _statusColors[status] ?? Colors.grey),
|
||||
),
|
||||
child: Text(
|
||||
status,
|
||||
style: TextStyle(color: _statusColors[status]),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -631,12 +638,12 @@ class _EventFormScreenState extends State<EventFormScreen> {
|
||||
margin: const EdgeInsets.only(left: 12),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: _statusColors[_status]?.withOpacity(0.2),
|
||||
color: _statusColors[_status]?.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: _statusColors[_status] ?? Colors.grey),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
blurRadius: 2,
|
||||
offset: const Offset(0, 1),
|
||||
),
|
||||
@@ -665,6 +672,58 @@ class _EventFormScreenState extends State<EventFormScreen> {
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 공개 접근 섹션
|
||||
_buildSectionTitle('공개 접근'),
|
||||
Builder(
|
||||
builder: (ctx) {
|
||||
final hasHash = _publicHashController.text.trim().isNotEmpty;
|
||||
final publicUrl = hasHash
|
||||
? UrlUtils.getEventPublicUrl(_publicHashController.text.trim())
|
||||
: '';
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: TextEditingController(text: publicUrl),
|
||||
readOnly: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '공개 URL',
|
||||
hintText: '해시 생성 시 공개 URL이 표시됩니다',
|
||||
prefixIcon: Icon(Icons.link),
|
||||
),
|
||||
maxLines: 1,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: hasHash
|
||||
? () {
|
||||
final url = UrlUtils.getEventPublicUrl(_publicHashController.text.trim());
|
||||
UrlUtils.copyUrlToClipboard(context, url);
|
||||
}
|
||||
: null,
|
||||
icon: const Icon(Icons.copy),
|
||||
label: const Text('URL 복사'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _generatePublicHash,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: Text(hasHash ? '재생성' : '생성'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 날짜 및 시간 섹션
|
||||
_buildSectionTitle('날짜 및 시간'),
|
||||
|
||||
@@ -721,6 +780,7 @@ class _EventFormScreenState extends State<EventFormScreen> {
|
||||
);
|
||||
|
||||
if (date != null) {
|
||||
if (!context.mounted) return;
|
||||
final time = await showTimePicker(
|
||||
context: context,
|
||||
initialTime: TimeOfDay.fromDateTime(_startDate),
|
||||
@@ -775,6 +835,7 @@ class _EventFormScreenState extends State<EventFormScreen> {
|
||||
);
|
||||
|
||||
if (date != null) {
|
||||
if (!context.mounted) return;
|
||||
final time = await showTimePicker(
|
||||
context: context,
|
||||
initialTime: _endDate != null
|
||||
@@ -830,6 +891,7 @@ class _EventFormScreenState extends State<EventFormScreen> {
|
||||
);
|
||||
|
||||
if (date != null) {
|
||||
if (!context.mounted) return;
|
||||
final time = await showTimePicker(
|
||||
context: context,
|
||||
initialTime: _registrationDeadline != null
|
||||
@@ -1087,14 +1149,10 @@ class _EventFormScreenState extends State<EventFormScreen> {
|
||||
),
|
||||
Tooltip(
|
||||
message: _publicHashController.text.isEmpty ? '새 해시 생성' : '해시 재생성',
|
||||
child: ElevatedButton.icon(
|
||||
icon: const Icon(Icons.refresh, size: 18),
|
||||
label: Text(_publicHashController.text.isEmpty ? '생성' : '재생성'),
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
color: Colors.blue,
|
||||
onPressed: _generatePublicHash,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.blue,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -1104,9 +1162,9 @@ class _EventFormScreenState extends State<EventFormScreen> {
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.withOpacity(0.1),
|
||||
color: Colors.blue.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.blue.withOpacity(0.3)),
|
||||
border: Border.all(color: Colors.blue.withValues(alpha: 0.3)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -1117,7 +1175,7 @@ class _EventFormScreenState extends State<EventFormScreen> {
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'https://bowling.example.com/events/${_publicHashController.text}',
|
||||
UrlUtils.getEventPublicUrl(_publicHashController.text),
|
||||
style: TextStyle(color: Colors.blue[700]),
|
||||
),
|
||||
),
|
||||
@@ -1147,6 +1205,7 @@ class _EventFormScreenState extends State<EventFormScreen> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextFormField(
|
||||
key: const ValueKey('access_password_field'),
|
||||
controller: _accessPasswordController,
|
||||
decoration: InputDecoration(
|
||||
labelText: '접근 비밀번호 (선택)',
|
||||
@@ -1236,6 +1295,7 @@ class _EventFormScreenState extends State<EventFormScreen> {
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
key: const ValueKey('save_event_button'),
|
||||
onPressed: _saveEvent,
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
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';
|
||||
import '../../services/auth_service.dart';
|
||||
import '../../services/club_service.dart';
|
||||
|
||||
@@ -13,6 +17,9 @@ import '../../widgets/loading_indicator.dart';
|
||||
import 'event_details_screen.dart';
|
||||
import 'event_form_screen.dart';
|
||||
import 'event_calendar_screen.dart';
|
||||
import '../../widgets/dialog_actions.dart';
|
||||
import '../../widgets/import_result_dialog.dart';
|
||||
import '../../theme/badges.dart';
|
||||
|
||||
class EventsScreen extends StatefulWidget {
|
||||
const EventsScreen({super.key});
|
||||
@@ -36,11 +43,185 @@ class _EventsScreenState extends State<EventsScreen> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 테스트 전용: 파일명을 포함한 바이트 데이터를 직접 주입해 업로드 플로우를 실행
|
||||
@visibleForTesting
|
||||
Future<void> importFromBytesForTest(String fileName, List<int> bytes) async {
|
||||
final ctx = context;
|
||||
final navigator = Navigator.of(ctx);
|
||||
final messenger = ScaffoldMessenger.of(ctx);
|
||||
final eventService = Provider.of<EventService>(ctx, listen: false);
|
||||
try {
|
||||
// 로딩 다이얼로그 표시
|
||||
if (!ctx.mounted) return;
|
||||
showDialog(
|
||||
context: ctx,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => const AlertDialog(
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 16),
|
||||
Text('파일 처리 중...'),
|
||||
SizedBox(height: 8),
|
||||
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: Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
final processedRows = parseResult['processedRows'] as int;
|
||||
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 error = parseResult['error'] as String?;
|
||||
|
||||
if (error != null) {
|
||||
navigator.pop();
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(error),
|
||||
duration: 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;
|
||||
showDialog(
|
||||
context: ctx,
|
||||
builder: (dialogContext) => ImportResultDialog(
|
||||
fileName: fileName,
|
||||
processedRows: processedRows,
|
||||
createdCount: results.length,
|
||||
invalidRows: invalidRows,
|
||||
invalidRowIndices: invalidRowIndices,
|
||||
invalidRowReasons: invalidRowReasons,
|
||||
onClose: () {
|
||||
Navigator.of(dialogContext).pop();
|
||||
_loadEvents();
|
||||
},
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (navigator.canPop()) navigator.pop();
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('파일 처리 중 오류가 발생했습니다: $e'),
|
||||
duration: const Duration(seconds: 4),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 테스트 편의를 위한 공개 훅: 오버레이 없이 복제 플로우를 직접 호출
|
||||
@visibleForTesting
|
||||
Future<void> invokeDuplicateForTest(Event event) async {
|
||||
final eventService = Provider.of<EventService>(context, listen: false);
|
||||
eventService.setClubId(event.clubId);
|
||||
await eventService.cloneEvent(event.id);
|
||||
}
|
||||
|
||||
// 검색어 복원
|
||||
Future<void> _restoreSearchQuery() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final saved = prefs.getString('events_search_query') ?? '';
|
||||
if (saved.isNotEmpty) {
|
||||
_searchController.text = saved;
|
||||
_searchQuery = saved;
|
||||
}
|
||||
} catch (_) {
|
||||
// ignore restore errors
|
||||
}
|
||||
}
|
||||
|
||||
// 검색어 저장
|
||||
Future<void> _saveSearchQuery(String value) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString('events_search_query', value);
|
||||
} catch (_) {
|
||||
// ignore save errors
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _duplicateEvent(Event event) async {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
try {
|
||||
final eventService = Provider.of<EventService>(context, listen: false);
|
||||
// cloneEvent는 clubId가 필요하므로 보장 차원에서 주입
|
||||
eventService.setClubId(event.clubId);
|
||||
await eventService.cloneEvent(event.id);
|
||||
if (!mounted) return;
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(content: Text('이벤트가 복제되었습니다')),
|
||||
);
|
||||
// 목록 새로고침
|
||||
await _loadEvents();
|
||||
} catch (e) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('이벤트 복제에 실패했습니다: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (!_isInit) {
|
||||
_loadEvents();
|
||||
_restoreSearchQuery().then((_) => _loadEvents());
|
||||
_isInit = true;
|
||||
}
|
||||
}
|
||||
@@ -50,6 +231,8 @@ class _EventsScreenState extends State<EventsScreen> {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
// 비동기 갭 이후 컨텍스트 직접 참조를 피하기 위해 메신저 캡처
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
try {
|
||||
final authService = Provider.of<AuthService>(context, listen: false);
|
||||
final clubService = Provider.of<ClubService>(context, listen: false);
|
||||
@@ -60,11 +243,10 @@ class _EventsScreenState extends State<EventsScreen> {
|
||||
await eventService.fetchClubEvents();
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('이벤트 목록을 불러오는데 실패했습니다: $e')));
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('이벤트 목록을 불러오는데 실패했습니다: $e')),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
@@ -182,6 +364,7 @@ class _EventsScreenState extends State<EventsScreen> {
|
||||
setState(() {
|
||||
_searchQuery = value;
|
||||
});
|
||||
_saveSearchQuery(value);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
@@ -369,11 +552,23 @@ class _EventsScreenState extends State<EventsScreen> {
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isPast ? Colors.grey[600] : Colors.black,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 4),
|
||||
if (event.status != null) ...[
|
||||
Wrap(
|
||||
spacing: 4,
|
||||
runSpacing: 4,
|
||||
children: [
|
||||
BadgeStyles.eventStatus(event.status!),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
Text(
|
||||
dateFormat.format(event.startDate),
|
||||
style: TextStyle(
|
||||
@@ -386,16 +581,21 @@ class _EventsScreenState extends State<EventsScreen> {
|
||||
style: TextStyle(
|
||||
color: isPast ? Colors.grey[500] : Colors.grey[600],
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: PopupMenuButton<String>(
|
||||
key: ValueKey('event_menu_${event.id}'),
|
||||
icon: const Icon(Icons.more_vert),
|
||||
onSelected: (value) {
|
||||
if (value == 'edit') {
|
||||
_showEditEventDialog(event);
|
||||
} else if (value == 'delete') {
|
||||
_showDeleteConfirmationDialog(event);
|
||||
} else if (value == 'duplicate') {
|
||||
_duplicateEvent(event);
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
@@ -409,6 +609,16 @@ class _EventsScreenState extends State<EventsScreen> {
|
||||
],
|
||||
),
|
||||
),
|
||||
const PopupMenuItem<String>(
|
||||
value: 'duplicate',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.copy, size: 18),
|
||||
SizedBox(width: 8),
|
||||
Text('복제'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const PopupMenuItem<String>(
|
||||
value: 'delete',
|
||||
child: Row(
|
||||
@@ -526,57 +736,57 @@ class _EventsScreenState extends State<EventsScreen> {
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
if (titleController.text.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('제목은 필수 입력 항목입니다')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
actions: DialogActions.confirm(
|
||||
onCancel: () => Navigator.of(context).pop(),
|
||||
onConfirm: () async {
|
||||
if (titleController.text.isEmpty) {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('제목은 필수 입력 항목입니다')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final eventService = Provider.of<EventService>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
try {
|
||||
final eventService = Provider.of<EventService>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
|
||||
// 날짜와 시간 결합
|
||||
final eventDateTime = DateTime(
|
||||
selectedDate.year,
|
||||
selectedDate.month,
|
||||
selectedDate.day,
|
||||
selectedTime.hour,
|
||||
selectedTime.minute,
|
||||
);
|
||||
// 날짜와 시간 결합
|
||||
final eventDateTime = DateTime(
|
||||
selectedDate.year,
|
||||
selectedDate.month,
|
||||
selectedDate.day,
|
||||
selectedTime.hour,
|
||||
selectedTime.minute,
|
||||
);
|
||||
|
||||
await eventService.updateEvent(event.id, {
|
||||
'title': titleController.text.trim(),
|
||||
'description': descriptionController.text.trim(),
|
||||
'location': locationController.text.trim(),
|
||||
'startDate': eventDateTime.toIso8601String(),
|
||||
});
|
||||
await eventService.updateEvent(event.id, {
|
||||
'title': titleController.text.trim(),
|
||||
'description': descriptionController.text.trim().isEmpty
|
||||
? null
|
||||
: descriptionController.text.trim(),
|
||||
'location': locationController.text.trim().isEmpty
|
||||
? null
|
||||
: locationController.text.trim(),
|
||||
'startDate': eventDateTime.toIso8601String(),
|
||||
});
|
||||
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('이벤트가 업데이트되었습니다')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('이벤트 업데이트에 실패했습니다: $e')));
|
||||
}
|
||||
},
|
||||
child: const Text('저장'),
|
||||
),
|
||||
],
|
||||
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')),
|
||||
);
|
||||
}
|
||||
},
|
||||
confirmText: '저장',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -602,37 +812,30 @@ class _EventsScreenState extends State<EventsScreen> {
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('이벤트 삭제'),
|
||||
content: Text('${event.title} 이벤트를 정말 삭제하시겠습니까?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
try {
|
||||
final eventService = Provider.of<EventService>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
|
||||
await eventService.deleteEvent(event.id);
|
||||
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop();
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('이벤트가 삭제되었습니다')));
|
||||
}
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('이벤트 삭제에 실패했습니다: $e')));
|
||||
}
|
||||
},
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
child: const Text('삭제'),
|
||||
),
|
||||
],
|
||||
actions: DialogActions.confirm(
|
||||
onCancel: () => Navigator.of(context).pop(),
|
||||
onConfirm: () async {
|
||||
try {
|
||||
final eventService = Provider.of<EventService>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
await eventService.deleteEvent(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: '삭제',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -696,36 +899,56 @@ class _EventsScreenState extends State<EventsScreen> {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
// 여기서는 예시 템플릿을 제공하는 기능을 구현할 수 있습니다.
|
||||
// 현재는 안내 메시지만 표시합니다.
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('예시 템플릿 준비 중입니다.')),
|
||||
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 헤더 복사'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('닫기'),
|
||||
),
|
||||
],
|
||||
actions: DialogActions.confirm(
|
||||
onCancel: () => Navigator.of(context).pop(),
|
||||
onConfirm: () => Navigator.of(context).pop(),
|
||||
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'],
|
||||
allowedExtensions: ['xlsx', 'xls', 'csv'],
|
||||
allowMultiple: false,
|
||||
);
|
||||
|
||||
@@ -739,7 +962,7 @@ class _EventsScreenState extends State<EventsScreen> {
|
||||
final bytes = file.bytes;
|
||||
|
||||
if (bytes == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('파일을 읽을 수 없습니다. 다른 파일을 선택해주세요.'),
|
||||
duration: Duration(seconds: 3),
|
||||
@@ -749,10 +972,11 @@ class _EventsScreenState extends State<EventsScreen> {
|
||||
}
|
||||
|
||||
// 로딩 다이얼로그 표시
|
||||
if (!ctx.mounted) return;
|
||||
showDialog(
|
||||
context: context,
|
||||
context: ctx,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => AlertDialog(
|
||||
builder: (_) => AlertDialog(
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
@@ -766,17 +990,40 @@ class _EventsScreenState extends State<EventsScreen> {
|
||||
),
|
||||
);
|
||||
|
||||
// EventExcelParser를 사용하여 엑셀 파일 파싱
|
||||
final parseResult = EventExcelParser.parseExcelBytes(bytes);
|
||||
// 확장자에 따라 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.of(context).pop(); // 로딩 다이얼로그 닫기
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
// 로딩 다이얼로그 닫기 및 에러 스낵바 표시
|
||||
navigator.pop();
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(error),
|
||||
duration: const Duration(seconds: 4),
|
||||
@@ -786,68 +1033,58 @@ class _EventsScreenState extends State<EventsScreen> {
|
||||
}
|
||||
|
||||
// 로딩 다이얼로그 업데이트
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop();
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => AlertDialog(
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const CircularProgressIndicator(),
|
||||
const SizedBox(height: 16),
|
||||
Text('이벤트 ${events.length}개 생성 중...'),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
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 eventService = Provider.of<EventService>(context, listen: false);
|
||||
// 이벤트 서비스를 통해 이벤트 생성 (비동기 이전에 캡처해 둔 것을 사용)
|
||||
final results = await eventService.createEventsFromFile(events);
|
||||
|
||||
// 로딩 다이얼로그 닫기
|
||||
if (mounted) Navigator.of(context).pop();
|
||||
if (navigator.canPop()) navigator.pop();
|
||||
|
||||
// 결과 표시
|
||||
if (mounted) {
|
||||
// 상세 결과 다이얼로그 표시
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('파일 처리 결과'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('파일명: $fileName'),
|
||||
const SizedBox(height: 8),
|
||||
Text('처리된 행: $processedRows개'),
|
||||
Text('생성된 이벤트: ${results.length}개'),
|
||||
if (invalidRows > 0) Text('유효하지 않은 행: $invalidRows개'),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
// 이벤트 목록 새로고침
|
||||
_loadEvents();
|
||||
},
|
||||
child: const Text('확인'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
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();
|
||||
_loadEvents();
|
||||
},
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
// 로딩 다이얼로그가 열려 있으면 닫기
|
||||
if (mounted) Navigator.of(context).pop();
|
||||
if (navigator.canPop()) navigator.pop();
|
||||
|
||||
// 사용자 친화적인 오류 메시지 표시
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
// 사용자 친화적인 오류 메시지 표시 (messenger는 사전 캡처되어 context 불필요)
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('파일 처리 중 오류가 발생했습니다: $e'),
|
||||
duration: const Duration(seconds: 4),
|
||||
|
||||
@@ -7,6 +7,8 @@ import '../../services/club_service.dart';
|
||||
import '../../services/member_service.dart';
|
||||
import '../../models/member_model.dart';
|
||||
import '../../utils/enum_mappings.dart';
|
||||
import '../../widgets/dialog_actions.dart';
|
||||
import '../../theme/badges.dart';
|
||||
|
||||
class MembersScreen extends StatefulWidget {
|
||||
const MembersScreen({super.key});
|
||||
@@ -36,6 +38,8 @@ class _MembersScreenState extends State<MembersScreen> {
|
||||
}
|
||||
|
||||
Future<void> _loadMembers() async {
|
||||
// 비동기 이후 컨텍스트 직접 참조를 피하기 위해 메신저를 미리 캡처
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
try {
|
||||
print('_loadMembers 호출됨');
|
||||
final authService = Provider.of<AuthService>(context, listen: false);
|
||||
@@ -59,11 +63,9 @@ class _MembersScreenState extends State<MembersScreen> {
|
||||
}
|
||||
} catch (e) {
|
||||
print('회원 목록 가져오기 오류: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('회원 목록을 불러오는데 실패했습니다: $e')),
|
||||
);
|
||||
}
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('회원 목록을 불러오는데 실패했습니다: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,6 +195,8 @@ class _MembersScreenState extends State<MembersScreen> {
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// 성별 아이콘
|
||||
@@ -230,8 +234,10 @@ class _MembersScreenState extends State<MembersScreen> {
|
||||
fontSize: 14,
|
||||
color: Colors.grey.shade700,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const SizedBox(height: 2),
|
||||
if (member.email.isNotEmpty)
|
||||
Text(
|
||||
member.email,
|
||||
@@ -239,19 +245,17 @@ class _MembersScreenState extends State<MembersScreen> {
|
||||
fontSize: 14,
|
||||
color: Colors.grey.shade700,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 4,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
memberTypeText, // 회원 유형으로 변경
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.blue.shade700,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// 회원 유형 배지 (공통 스타일 적용)
|
||||
BadgeStyles.memberType(memberTypeText),
|
||||
// 가입일 표시
|
||||
if (member.joinDate != null)
|
||||
Text(
|
||||
@@ -261,23 +265,8 @@ class _MembersScreenState extends State<MembersScreen> {
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// 상태 표시
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: getStatusColor(member.status ?? 'inactive'),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
getStatusLabel(member.status),
|
||||
style: TextStyle(
|
||||
color: getStatusTextColor(member.status ?? 'inactive'),
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
// 상태 표시 (공통 배지 적용)
|
||||
BadgeStyles.memberStatus(getStatusLabel(member.status)),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -328,8 +317,8 @@ class _MembersScreenState extends State<MembersScreen> {
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
builder: (dialogContext) => StatefulBuilder(
|
||||
builder: (dialogContext, setState) {
|
||||
return AlertDialog(
|
||||
title: const Text('회원 추가'),
|
||||
content: SingleChildScrollView(
|
||||
@@ -395,14 +384,16 @@ class _MembersScreenState extends State<MembersScreen> {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<String>(
|
||||
key: const Key('members_add_gender_dropdown'),
|
||||
value: selectedGender,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '성별',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: const [
|
||||
DropdownMenuItem(value: 'male', child: Text('남성')),
|
||||
DropdownMenuItem(value: 'female', child: Text('여성')),
|
||||
isExpanded: true,
|
||||
items: [
|
||||
DropdownMenuItem(value: 'male', child: Text('남성', maxLines: 1, overflow: TextOverflow.ellipsis)),
|
||||
DropdownMenuItem(value: 'female', child: Text('여성', maxLines: 1, overflow: TextOverflow.ellipsis)),
|
||||
],
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
@@ -414,15 +405,17 @@ class _MembersScreenState extends State<MembersScreen> {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<String>(
|
||||
key: const Key('members_add_member_type_dropdown'),
|
||||
value: selectedMemberType,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '회원 유형',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: const [
|
||||
DropdownMenuItem(value: 'regular', child: Text('정회원')),
|
||||
DropdownMenuItem(value: 'associate', child: Text('준회원')),
|
||||
DropdownMenuItem(value: 'guest', child: Text('게스트')),
|
||||
isExpanded: true,
|
||||
items: [
|
||||
DropdownMenuItem(value: 'regular', child: Text('정회원', maxLines: 1, overflow: TextOverflow.ellipsis)),
|
||||
DropdownMenuItem(value: 'associate', child: Text('준회원', maxLines: 1, overflow: TextOverflow.ellipsis)),
|
||||
DropdownMenuItem(value: 'guest', child: Text('게스트', maxLines: 1, overflow: TextOverflow.ellipsis)),
|
||||
],
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
@@ -434,14 +427,16 @@ class _MembersScreenState extends State<MembersScreen> {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<String>(
|
||||
key: const Key('members_add_status_dropdown'),
|
||||
value: selectedStatus,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '상태',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: const [
|
||||
DropdownMenuItem(value: 'active', child: Text('활성')),
|
||||
DropdownMenuItem(value: 'inactive', child: Text('비활성')),
|
||||
isExpanded: true,
|
||||
items: [
|
||||
DropdownMenuItem(value: 'active', child: Text('활성', maxLines: 1, overflow: TextOverflow.ellipsis)),
|
||||
DropdownMenuItem(value: 'inactive', child: Text('비활성', maxLines: 1, overflow: TextOverflow.ellipsis)),
|
||||
],
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
@@ -454,78 +449,65 @@ class _MembersScreenState extends State<MembersScreen> {
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
if (nameController.text.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('이름은 필수 입력 항목입니다')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final memberService = Provider.of<MemberService>(context, listen: false);
|
||||
final clubService = Provider.of<ClubService>(context, listen: false);
|
||||
|
||||
if (clubService.currentClub != null) {
|
||||
// 회원 추가 데이터 준비
|
||||
final memberData = <String, dynamic>{
|
||||
'name': nameController.text.trim(),
|
||||
'clubId': clubService.currentClub!.id,
|
||||
'gender': selectedGender,
|
||||
'memberType': selectedMemberType,
|
||||
'status': selectedStatus,
|
||||
'handicap': int.tryParse(handicapController.text) ?? 0,
|
||||
'isActive': selectedStatus == 'active',
|
||||
};
|
||||
|
||||
// 가입일 추가
|
||||
try {
|
||||
final joinDate = parseDate(joinDateController.text);
|
||||
if (joinDate != null) {
|
||||
memberData['joinDate'] = joinDate.toIso8601String();
|
||||
}
|
||||
} catch (e) {
|
||||
// 가입일 파싱 오류 무시
|
||||
}
|
||||
|
||||
// 이메일이 비어있으면 null로 설정, 그렇지 않으면 트림한 값 사용
|
||||
if (emailController.text.trim().isEmpty) {
|
||||
memberData['email'] = null;
|
||||
} else {
|
||||
memberData['email'] = emailController.text.trim();
|
||||
}
|
||||
|
||||
// 전화번호가 비어있으면 null로 설정, 그렇지 않으면 트림한 값 사용
|
||||
if (phoneController.text.trim().isEmpty) {
|
||||
memberData['phone'] = null;
|
||||
} else {
|
||||
memberData['phone'] = phoneController.text.trim();
|
||||
}
|
||||
|
||||
await memberService.addMember(memberData);
|
||||
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('회원이 추가되었습니다')),
|
||||
);
|
||||
actions: DialogActions.confirm(
|
||||
onCancel: () => Navigator.of(dialogContext).pop(),
|
||||
onConfirm: () async {
|
||||
// await 이전에 컨텍스트 파생 객체 캡처
|
||||
final navigator = Navigator.of(dialogContext);
|
||||
final messenger = ScaffoldMessenger.of(dialogContext);
|
||||
if (nameController.text.isEmpty) {
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(content: Text('이름은 필수 입력 항목입니다')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final memberService = Provider.of<MemberService>(context, listen: false);
|
||||
final clubService = Provider.of<ClubService>(context, listen: false);
|
||||
|
||||
if (clubService.currentClub != null) {
|
||||
// 회원 추가 데이터 준비
|
||||
final memberData = <String, dynamic>{
|
||||
'name': nameController.text.trim(),
|
||||
'clubId': clubService.currentClub!.id,
|
||||
'gender': selectedGender,
|
||||
'memberType': selectedMemberType,
|
||||
'status': selectedStatus,
|
||||
'handicap': int.tryParse(handicapController.text) ?? 0,
|
||||
'isActive': selectedStatus == 'active',
|
||||
};
|
||||
|
||||
// 가입일 추가
|
||||
try {
|
||||
final joinDate = parseDate(joinDateController.text);
|
||||
if (joinDate != null) {
|
||||
memberData['joinDate'] = joinDate.toIso8601String();
|
||||
}
|
||||
} catch (e) {
|
||||
// 가입일 파싱 오류 무시
|
||||
}
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('회원 추가에 실패했습니다: $e')),
|
||||
|
||||
// 이메일이 비어있으면 null로 설정, 그렇지 않으면 트림한 값 사용
|
||||
memberData['email'] = emailController.text.trim().isEmpty ? null : emailController.text.trim();
|
||||
// 전화번호가 비어있으면 null로 설정, 그렇지 않으면 트림한 값 사용
|
||||
memberData['phone'] = phoneController.text.trim().isEmpty ? null : phoneController.text.trim();
|
||||
|
||||
await memberService.addMember(memberData);
|
||||
// 결과 알림 및 다이얼로그 닫기
|
||||
navigator.pop();
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(content: Text('회원이 추가되었습니다')),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Text('추가'),
|
||||
),
|
||||
],
|
||||
} catch (e) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('회원 추가에 실패했습니다: $e')),
|
||||
);
|
||||
}
|
||||
},
|
||||
confirmText: '추가',
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -619,7 +601,7 @@ class _MembersScreenState extends State<MembersScreen> {
|
||||
// 폼 컨트롤러 초기화
|
||||
final nameController = TextEditingController(text: member.name);
|
||||
final phoneController = TextEditingController(text: member.phone ?? '');
|
||||
final emailController = TextEditingController(text: member.email ?? '');
|
||||
final emailController = TextEditingController(text: member.email);
|
||||
final handicapController = TextEditingController(text: member.handicap?.toString() ?? '0');
|
||||
final joinDateController = TextEditingController(text: formatDate(member.joinDate));
|
||||
|
||||
@@ -630,8 +612,8 @@ class _MembersScreenState extends State<MembersScreen> {
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
builder: (dialogContext) => StatefulBuilder(
|
||||
builder: (dialogContext, setState) {
|
||||
return AlertDialog(
|
||||
title: Text(isEditing ? '회원 정보 수정' : '회원 정보'),
|
||||
content: SingleChildScrollView(
|
||||
@@ -718,19 +700,21 @@ class _MembersScreenState extends State<MembersScreen> {
|
||||
// 성별 필드 - 상단에 없으므로 항상 표시
|
||||
buildFormField('성별', isEditing ?
|
||||
DropdownButtonFormField<String>(
|
||||
key: const Key('members_edit_gender_dropdown'),
|
||||
value: selectedGender,
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
),
|
||||
isExpanded: true,
|
||||
items: [
|
||||
DropdownMenuItem<String>(
|
||||
value: 'male',
|
||||
child: Text('남성'),
|
||||
child: Text('남성', maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
DropdownMenuItem<String>(
|
||||
value: 'female',
|
||||
child: Text('여성'),
|
||||
child: Text('여성', maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
],
|
||||
onChanged: (value) {
|
||||
@@ -745,31 +729,33 @@ class _MembersScreenState extends State<MembersScreen> {
|
||||
// 회원 유형 필드 - 수정 모드에서만 표시 (상단에 이미 표시됨)
|
||||
if (isEditing)
|
||||
buildFormField('회원 유형', DropdownButtonFormField<String>(
|
||||
key: const Key('members_edit_member_type_dropdown'),
|
||||
value: selectedMemberType,
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
),
|
||||
isExpanded: true,
|
||||
items: [
|
||||
DropdownMenuItem<String>(
|
||||
value: 'regular',
|
||||
child: Text('정회원'),
|
||||
child: Text('정회원', maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
DropdownMenuItem<String>(
|
||||
value: 'associate',
|
||||
child: Text('준회원'),
|
||||
child: Text('준회원', maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
DropdownMenuItem<String>(
|
||||
value: 'guest',
|
||||
child: Text('게스트'),
|
||||
child: Text('게스트', maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
DropdownMenuItem<String>(
|
||||
value: 'manager',
|
||||
child: Text('운영진'),
|
||||
child: Text('운영진', maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
DropdownMenuItem<String>(
|
||||
value: 'owner',
|
||||
child: Text('모임장'),
|
||||
child: Text('모임장', maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
],
|
||||
onChanged: (value) {
|
||||
@@ -817,23 +803,25 @@ class _MembersScreenState extends State<MembersScreen> {
|
||||
// 상태 필드 - 수정 모드에서만 표시 (상단에 이미 표시됨)
|
||||
if (isEditing)
|
||||
buildFormField('상태', DropdownButtonFormField<String>(
|
||||
key: const Key('members_edit_status_dropdown'),
|
||||
value: selectedStatus,
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
),
|
||||
isExpanded: true,
|
||||
items: [
|
||||
DropdownMenuItem<String>(
|
||||
value: 'active',
|
||||
child: Text('활성'),
|
||||
child: Text('활성', maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
DropdownMenuItem<String>(
|
||||
value: 'inactive',
|
||||
child: Text('비활성'),
|
||||
child: Text('비활성', maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
DropdownMenuItem<String>(
|
||||
value: 'suspended',
|
||||
child: Text('정지'),
|
||||
child: Text('정지', maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
],
|
||||
onChanged: (value) {
|
||||
@@ -848,7 +836,7 @@ class _MembersScreenState extends State<MembersScreen> {
|
||||
InkWell(
|
||||
onTap: () async {
|
||||
final DateTime? picked = await showDatePicker(
|
||||
context: context,
|
||||
context: dialogContext,
|
||||
initialDate: member.joinDate ?? DateTime.now(),
|
||||
firstDate: DateTime(2000),
|
||||
lastDate: DateTime.now(),
|
||||
@@ -900,16 +888,19 @@ class _MembersScreenState extends State<MembersScreen> {
|
||||
actions: [
|
||||
// 취소 버튼
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
// 수정/저장 버튼
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
// 비동기 작업 전 컨텍스트 파생 객체 캡처
|
||||
final navigator = Navigator.of(dialogContext);
|
||||
final messenger = ScaffoldMessenger.of(dialogContext);
|
||||
if (isEditing) {
|
||||
// 수정 모드에서 저장 버튼 클릭 시
|
||||
if (nameController.text.trim().isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(content: Text('이름은 필수 입력 항목입니다.')),
|
||||
);
|
||||
return;
|
||||
@@ -952,12 +943,12 @@ class _MembersScreenState extends State<MembersScreen> {
|
||||
updatedMember.id,
|
||||
memberData,
|
||||
).then((_) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(content: Text('회원 정보가 업데이트되었습니다.')),
|
||||
);
|
||||
Navigator.of(context).pop();
|
||||
navigator.pop();
|
||||
}).catchError((error) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('오류: ${error.toString()}')),
|
||||
);
|
||||
});
|
||||
@@ -981,37 +972,31 @@ class _MembersScreenState extends State<MembersScreen> {
|
||||
void _showDeleteConfirmationDialog(Member member) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('회원 삭제'),
|
||||
content: Text('${member.name} 회원을 정말 삭제하시겠습니까?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
try {
|
||||
final memberService = Provider.of<MemberService>(context, listen: false);
|
||||
|
||||
await memberService.deleteMember(member.id);
|
||||
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('회원이 삭제되었습니다')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('회원 삭제에 실패했습니다: $e')),
|
||||
);
|
||||
}
|
||||
},
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
child: const Text('삭제'),
|
||||
),
|
||||
],
|
||||
actions: DialogActions.confirm(
|
||||
onCancel: () => Navigator.of(dialogContext).pop(),
|
||||
onConfirm: () async {
|
||||
// await 이전에 컨텍스트 파생 객체 캡처
|
||||
final navigator = Navigator.of(dialogContext);
|
||||
final messenger = ScaffoldMessenger.of(dialogContext);
|
||||
try {
|
||||
final memberService = Provider.of<MemberService>(context, listen: false);
|
||||
await memberService.deleteMember(member.id);
|
||||
navigator.pop();
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(content: Text('회원이 삭제되었습니다')),
|
||||
);
|
||||
} catch (e) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('회원 삭제에 실패했습니다: $e')),
|
||||
);
|
||||
}
|
||||
},
|
||||
destructive: true,
|
||||
confirmText: '삭제',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../models/participant_model.dart';
|
||||
import '../../services/event_service.dart';
|
||||
import '../../widgets/loading_indicator.dart';
|
||||
import '../../utils/enum_mappings.dart';
|
||||
|
||||
class ParticipantFormScreen extends StatefulWidget {
|
||||
final String eventId;
|
||||
@@ -35,13 +35,22 @@ class _ParticipantFormScreenState extends State<ParticipantFormScreen> {
|
||||
String? _status;
|
||||
bool _isPaid = false;
|
||||
|
||||
// 참가자 상태 옵션
|
||||
final List<String> _statusOptions = ['등록됨', '확인됨', '취소됨', '참석함'];
|
||||
// 참가자 상태 옵션 (enum_mappings 사용)
|
||||
late final List<String> _statusKoOptions;
|
||||
late final Map<String, String> _statusEnToKo;
|
||||
late final Map<String, String> _statusKoToEn;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// enum 매핑 구성
|
||||
_statusEnToKo = Map<String, String>.from(participantStatusOptions);
|
||||
_statusKoToEn = {
|
||||
for (final e in participantStatusOptions.entries) e.value: e.key,
|
||||
};
|
||||
_statusKoOptions = participantStatusOptions.values.toList(growable: false);
|
||||
|
||||
// 수정 모드인 경우 기존 데이터 로드
|
||||
if (widget.participant != null) {
|
||||
_nameController.text = widget.participant!.name ?? '';
|
||||
@@ -50,11 +59,14 @@ class _ParticipantFormScreenState extends State<ParticipantFormScreen> {
|
||||
_notesController.text = widget.participant!.notes ?? '';
|
||||
_paidAmountController.text = widget.participant!.paidAmount?.toString() ?? '';
|
||||
|
||||
_status = widget.participant!.status;
|
||||
// 서버/모델 상태(영문)를 UI 드롭다운(한글)으로 변환
|
||||
final currentStatusEn = widget.participant!.status ?? 'pending';
|
||||
final currentStatusKo = _statusEnToKo[currentStatusEn] ?? _statusKoOptions.first;
|
||||
_status = _statusKoOptions.contains(currentStatusKo) ? currentStatusKo : _statusKoOptions.first;
|
||||
_isPaid = widget.participant!.isPaid;
|
||||
} else {
|
||||
// 새 참가자 추가 시 기본값 설정
|
||||
_status = _statusOptions[0];
|
||||
_status = _statusKoOptions.first; // pending
|
||||
_isPaid = false;
|
||||
}
|
||||
}
|
||||
@@ -88,8 +100,11 @@ class _ParticipantFormScreenState extends State<ParticipantFormScreen> {
|
||||
'name': _nameController.text.trim(),
|
||||
'email': _emailController.text.trim().isEmpty ? null : _emailController.text.trim(),
|
||||
'phoneNumber': _phoneNumberController.text.trim().isEmpty ? null : _phoneNumberController.text.trim(),
|
||||
'status': _status,
|
||||
// 서버는 영문 상태를 기대, UI는 한글 상태를 사용하므로 변환 (pending/confirmed/canceled)
|
||||
'status': _statusKoToEn[_status ?? _statusKoOptions.first] ?? 'pending',
|
||||
// 결제 상태는 enum 키 사용 (unpaid/paid)
|
||||
'isPaid': _isPaid,
|
||||
'paymentStatus': _isPaid ? 'paid' : 'unpaid',
|
||||
'paidAmount': _paidAmountController.text.isEmpty ? null : double.parse(_paidAmountController.text),
|
||||
'notes': _notesController.text.trim().isEmpty ? null : _notesController.text.trim(),
|
||||
};
|
||||
@@ -149,9 +164,18 @@ class _ParticipantFormScreenState extends State<ParticipantFormScreen> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 게스트 사전입력 버튼
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _showGuestPrefillSheet,
|
||||
icon: const Icon(Icons.person_add),
|
||||
label: const Text('게스트 사전입력'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// 기본 정보 섹션
|
||||
_buildSectionTitle('기본 정보'),
|
||||
|
||||
// 이름
|
||||
TextFormField(
|
||||
controller: _nameController,
|
||||
@@ -167,7 +191,6 @@ class _ParticipantFormScreenState extends State<ParticipantFormScreen> {
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 이메일
|
||||
TextFormField(
|
||||
controller: _emailController,
|
||||
@@ -178,7 +201,6 @@ class _ParticipantFormScreenState extends State<ParticipantFormScreen> {
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
validator: (value) {
|
||||
if (value != null && value.isNotEmpty) {
|
||||
// 간단한 이메일 형식 검증
|
||||
if (!value.contains('@') || !value.contains('.')) {
|
||||
return '유효한 이메일 주소를 입력해주세요';
|
||||
}
|
||||
@@ -187,7 +209,6 @@ class _ParticipantFormScreenState extends State<ParticipantFormScreen> {
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 전화번호
|
||||
TextFormField(
|
||||
controller: _phoneNumberController,
|
||||
@@ -198,22 +219,22 @@ class _ParticipantFormScreenState extends State<ParticipantFormScreen> {
|
||||
keyboardType: TextInputType.phone,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 상태 섹션
|
||||
_buildSectionTitle('상태 정보'),
|
||||
|
||||
// 참가자 상태
|
||||
DropdownButtonFormField<String>(
|
||||
value: _status,
|
||||
key: const Key('participant_form_status_dropdown'),
|
||||
value: _statusKoOptions.contains(_status) ? _status : _statusKoOptions.first,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '참가자 상태',
|
||||
),
|
||||
items: _statusOptions.map((status) {
|
||||
return DropdownMenuItem(
|
||||
value: status,
|
||||
child: Text(status),
|
||||
);
|
||||
}).toList(),
|
||||
isExpanded: true,
|
||||
items: _statusKoOptions
|
||||
.map((status) => DropdownMenuItem<String>(
|
||||
value: status,
|
||||
child: Text(status, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_status = value;
|
||||
@@ -221,7 +242,6 @@ class _ParticipantFormScreenState extends State<ParticipantFormScreen> {
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 결제 여부
|
||||
SwitchListTile(
|
||||
title: const Text('결제 완료'),
|
||||
@@ -232,7 +252,6 @@ class _ParticipantFormScreenState extends State<ParticipantFormScreen> {
|
||||
});
|
||||
},
|
||||
),
|
||||
|
||||
// 결제 금액
|
||||
TextFormField(
|
||||
controller: _paidAmountController,
|
||||
@@ -252,10 +271,8 @@ class _ParticipantFormScreenState extends State<ParticipantFormScreen> {
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 메모 섹션
|
||||
_buildSectionTitle('추가 정보'),
|
||||
|
||||
// 메모
|
||||
TextFormField(
|
||||
controller: _notesController,
|
||||
@@ -266,7 +283,6 @@ class _ParticipantFormScreenState extends State<ParticipantFormScreen> {
|
||||
maxLines: 3,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// 저장 버튼
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
@@ -288,6 +304,92 @@ class _ParticipantFormScreenState extends State<ParticipantFormScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
// 게스트 사전입력 BottomSheet
|
||||
void _showGuestPrefillSheet() {
|
||||
final nameCtrl = TextEditingController(text: _nameController.text);
|
||||
final emailCtrl = TextEditingController(text: _emailController.text);
|
||||
final phoneCtrl = TextEditingController(text: _phoneNumberController.text);
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
builder: (ctx) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: 16,
|
||||
right: 16,
|
||||
bottom: MediaQuery.of(ctx).viewInsets.bottom + 16,
|
||||
top: 16,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('게스트 사전입력', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: nameCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '이름 *',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: emailCtrl,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '이메일',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: phoneCtrl,
|
||||
keyboardType: TextInputType.phone,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '전화번호',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
if (nameCtrl.text.trim().isEmpty) {
|
||||
ScaffoldMessenger.of(ctx).showSnackBar(
|
||||
const SnackBar(content: Text('이름은 필수 입력 항목입니다')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_nameController.text = nameCtrl.text.trim();
|
||||
_emailController.text = emailCtrl.text.trim();
|
||||
_phoneNumberController.text = phoneCtrl.text.trim();
|
||||
});
|
||||
Navigator.of(ctx).pop();
|
||||
},
|
||||
child: const Text('적용'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// 섹션 제목 위젯
|
||||
Widget _buildSectionTitle(String title) {
|
||||
return Column(
|
||||
|
||||
@@ -13,11 +13,11 @@ class ScoreFormScreen extends StatefulWidget {
|
||||
final List<Participant> participants;
|
||||
|
||||
const ScoreFormScreen({
|
||||
Key? key,
|
||||
super.key,
|
||||
required this.eventId,
|
||||
this.score,
|
||||
required this.participants,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
@override
|
||||
State<ScoreFormScreen> createState() => _ScoreFormScreenState();
|
||||
@@ -45,7 +45,7 @@ class _ScoreFormScreenState extends State<ScoreFormScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
|
||||
// 수정 모드인 경우 기존 데이터 로드
|
||||
if (widget.score != null) {
|
||||
_notesController.text = widget.score!.notes ?? '';
|
||||
@@ -53,18 +53,18 @@ class _ScoreFormScreenState extends State<ScoreFormScreen> {
|
||||
_scoreDate = widget.score!.date;
|
||||
_totalScoreController.text = widget.score!.totalScore.toString();
|
||||
_totalScore = widget.score!.totalScore;
|
||||
|
||||
|
||||
// 핸디캡 설정
|
||||
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();
|
||||
@@ -96,7 +96,7 @@ class _ScoreFormScreenState extends State<ScoreFormScreen> {
|
||||
total += int.tryParse(controller.text) ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
setState(() {
|
||||
_totalScore = total;
|
||||
_totalScoreController.text = total.toString();
|
||||
@@ -113,27 +113,34 @@ class _ScoreFormScreenState extends State<ScoreFormScreen> {
|
||||
if (!_formKey.currentState!.validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
|
||||
try {
|
||||
final eventService = Provider.of<EventService>(context, listen: false);
|
||||
|
||||
|
||||
// 점수 데이터 준비
|
||||
final Map<String, dynamic> scoreData = {
|
||||
'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(),
|
||||
'handicap': _handicapController.text.isEmpty
|
||||
? null
|
||||
: int.parse(_handicapController.text),
|
||||
'notes': _notesController.text.trim().isEmpty
|
||||
? null
|
||||
: _notesController.text.trim(),
|
||||
};
|
||||
|
||||
|
||||
// 프레임별 입력 모드인 경우
|
||||
if (_useFrameInput) {
|
||||
final frames = _frameControllers
|
||||
.map((controller) => controller.text.isEmpty ? 0 : int.parse(controller.text))
|
||||
.map(
|
||||
(controller) =>
|
||||
controller.text.isEmpty ? 0 : int.parse(controller.text),
|
||||
)
|
||||
.toList();
|
||||
scoreData['frames'] = frames;
|
||||
scoreData['totalScore'] = _totalScore;
|
||||
@@ -142,25 +149,29 @@ class _ScoreFormScreenState extends State<ScoreFormScreen> {
|
||||
scoreData['totalScore'] = int.tryParse(_totalScoreController.text) ?? 0;
|
||||
scoreData['frames'] = []; // 빈 프레임 배열 전송
|
||||
}
|
||||
|
||||
|
||||
if (widget.score == null) {
|
||||
// 새 점수 추가
|
||||
await eventService.addScore(widget.eventId, scoreData);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('점수가 추가되었습니다')),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('점수가 추가되었습니다')));
|
||||
}
|
||||
} else {
|
||||
// 기존 점수 수정
|
||||
await eventService.updateScore(widget.eventId, widget.score!.id, scoreData);
|
||||
await eventService.updateScore(
|
||||
widget.eventId,
|
||||
widget.score!.id,
|
||||
scoreData,
|
||||
);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('점수가 수정되었습니다')),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('점수가 수정되었습니다')));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop(true); // 성공 결과와 함께 이전 화면으로 돌아가기
|
||||
}
|
||||
@@ -169,10 +180,10 @@ class _ScoreFormScreenState extends State<ScoreFormScreen> {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('점수 저장에 실패했습니다: $e')),
|
||||
);
|
||||
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('점수 저장에 실패했습니다: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -183,10 +194,7 @@ class _ScoreFormScreenState extends State<ScoreFormScreen> {
|
||||
appBar: AppBar(
|
||||
title: Text(widget.score == null ? '점수 추가' : '점수 수정'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.save),
|
||||
onPressed: _saveScore,
|
||||
),
|
||||
IconButton(icon: const Icon(Icons.save), onPressed: _saveScore),
|
||||
],
|
||||
),
|
||||
body: _isLoading
|
||||
@@ -200,17 +208,21 @@ class _ScoreFormScreenState extends State<ScoreFormScreen> {
|
||||
children: [
|
||||
// 기본 정보 섹션
|
||||
_buildSectionTitle('기본 정보'),
|
||||
|
||||
|
||||
// 참가자 선택
|
||||
DropdownButtonFormField<String>(
|
||||
key: const Key('score_form_participant_dropdown'),
|
||||
value: _selectedParticipantId,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '참가자 *',
|
||||
),
|
||||
decoration: const InputDecoration(labelText: '참가자 *'),
|
||||
isExpanded: true,
|
||||
items: widget.participants.map((participant) {
|
||||
return DropdownMenuItem(
|
||||
value: participant.memberId,
|
||||
child: Text(participant.name ?? '이름 없음'),
|
||||
child: Text(
|
||||
participant.name ?? '이름 없음',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (value) {
|
||||
@@ -226,7 +238,7 @@ class _ScoreFormScreenState extends State<ScoreFormScreen> {
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
|
||||
// 날짜 선택
|
||||
ListTile(
|
||||
title: const Text('점수 기록 날짜 *'),
|
||||
@@ -241,7 +253,7 @@ class _ScoreFormScreenState extends State<ScoreFormScreen> {
|
||||
firstDate: DateTime(2020),
|
||||
lastDate: DateTime(2030),
|
||||
);
|
||||
|
||||
|
||||
if (date != null && mounted) {
|
||||
setState(() {
|
||||
_scoreDate = date;
|
||||
@@ -250,10 +262,10 @@ class _ScoreFormScreenState extends State<ScoreFormScreen> {
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
|
||||
// 점수 입력 섹션
|
||||
_buildSectionTitle('점수 입력'),
|
||||
|
||||
|
||||
// 점수 입력 모드 전환 스위치
|
||||
SwitchListTile(
|
||||
title: const Text('프레임별 점수 입력'),
|
||||
@@ -274,88 +286,92 @@ class _ScoreFormScreenState extends State<ScoreFormScreen> {
|
||||
},
|
||||
),
|
||||
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,
|
||||
_useFrameInput
|
||||
? Column(
|
||||
children: [
|
||||
const Text(
|
||||
'계산된 총점:',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
// 프레임별 점수 입력
|
||||
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);
|
||||
},
|
||||
),
|
||||
Text(
|
||||
_totalScore.toString(),
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.blue,
|
||||
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;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
) : 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,
|
||||
@@ -386,9 +402,7 @@ class _ScoreFormScreenState extends State<ScoreFormScreen> {
|
||||
children: [
|
||||
const Text(
|
||||
'핸디캡 포함 총점: ',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
Text(
|
||||
'${_totalScore + (int.tryParse(_handicapController.text) ?? 0)}',
|
||||
@@ -401,10 +415,10 @@ class _ScoreFormScreenState extends State<ScoreFormScreen> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
|
||||
// 메모 섹션
|
||||
_buildSectionTitle('추가 정보'),
|
||||
|
||||
|
||||
// 메모
|
||||
TextFormField(
|
||||
controller: _notesController,
|
||||
@@ -415,7 +429,7 @@ class _ScoreFormScreenState extends State<ScoreFormScreen> {
|
||||
maxLines: 3,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
|
||||
// 저장 버튼
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
@@ -482,10 +496,7 @@ class _ScoreFormScreenState extends State<ScoreFormScreen> {
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const Divider(),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
@@ -6,6 +6,7 @@ import '../../models/participant_model.dart';
|
||||
import '../../models/team_model.dart';
|
||||
import '../../services/event_service.dart';
|
||||
import '../../widgets/loading_indicator.dart';
|
||||
import '../../widgets/dialog_actions.dart';
|
||||
|
||||
class TeamGeneratorScreen extends StatefulWidget {
|
||||
final String eventId;
|
||||
@@ -40,9 +41,9 @@ class _TeamGeneratorScreenState extends State<TeamGeneratorScreen> {
|
||||
// 모든 참가자 기본 선택
|
||||
for (var participant in widget.participants) {
|
||||
if (participant.status == '참석함' || participant.status == '확인됨') {
|
||||
_selectedParticipants[participant.memberId ?? ''] = true;
|
||||
_selectedParticipants[participant.memberId] = true;
|
||||
} else {
|
||||
_selectedParticipants[participant.memberId ?? ''] = false;
|
||||
_selectedParticipants[participant.memberId] = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,7 +112,7 @@ class _TeamGeneratorScreenState extends State<TeamGeneratorScreen> {
|
||||
: '',
|
||||
eventId: widget.eventId,
|
||||
name: '${index + 1}',
|
||||
memberIds: teams[index].map((p) => p.memberId ?? '').toList(),
|
||||
memberIds: teams[index].map((p) => p.memberId).toList(),
|
||||
description: '자동 생성된 팀',
|
||||
),
|
||||
);
|
||||
@@ -145,17 +146,8 @@ class _TeamGeneratorScreenState extends State<TeamGeneratorScreen> {
|
||||
|
||||
try {
|
||||
final eventService = Provider.of<EventService>(context, listen: false);
|
||||
|
||||
// 팀 데이터 준비
|
||||
final teamsData = _generatedTeams.map((team) => {
|
||||
'eventId': widget.eventId,
|
||||
'name': team.name,
|
||||
'memberIds': team.memberIds,
|
||||
'description': team.description,
|
||||
}).toList();
|
||||
|
||||
// 팀 저장
|
||||
await eventService.saveTeams(widget.eventId, teamsData);
|
||||
// 팀 저장 (서비스는 List<Team>을 기대함)
|
||||
await eventService.saveTeams(widget.eventId, _generatedTeams);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -301,7 +293,7 @@ class _TeamGeneratorScreenState extends State<TeamGeneratorScreen> {
|
||||
value: _selectedParticipants[participant.memberId] ?? false,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_selectedParticipants[participant.memberId ?? ''] = value ?? false;
|
||||
_selectedParticipants[participant.memberId] = value ?? false;
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -360,27 +352,22 @@ class _TeamGeneratorScreenState extends State<TeamGeneratorScreen> {
|
||||
},
|
||||
controller: TextEditingController(text: team.name),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_generatedTeams[teamIndex] = Team(
|
||||
id: team.id,
|
||||
eventId: team.eventId,
|
||||
name: newName,
|
||||
memberIds: team.memberIds,
|
||||
description: team.description,
|
||||
);
|
||||
});
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('저장'),
|
||||
),
|
||||
],
|
||||
actions: DialogActions.confirm(
|
||||
onCancel: () => Navigator.of(context).pop(),
|
||||
onConfirm: () {
|
||||
setState(() {
|
||||
_generatedTeams[teamIndex] = Team(
|
||||
id: team.id,
|
||||
eventId: team.eventId,
|
||||
name: newName,
|
||||
memberIds: team.memberIds,
|
||||
description: team.description,
|
||||
);
|
||||
});
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
confirmText: '저장',
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -416,45 +403,38 @@ class _TeamGeneratorScreenState extends State<TeamGeneratorScreen> {
|
||||
}
|
||||
},
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
if (targetTeamIndex != teamIndex) {
|
||||
setState(() {
|
||||
// 현재 팀에서 제거
|
||||
_generatedTeams[teamIndex] = Team(
|
||||
id: team.id,
|
||||
eventId: team.eventId,
|
||||
name: team.name,
|
||||
memberIds: team.memberIds
|
||||
.where((id) => id != member.memberId)
|
||||
.toList(),
|
||||
description: team.description,
|
||||
);
|
||||
|
||||
// 대상 팀에 추가
|
||||
final targetTeam = _generatedTeams[targetTeamIndex];
|
||||
_generatedTeams[targetTeamIndex] = Team(
|
||||
id: targetTeam.id,
|
||||
eventId: targetTeam.eventId,
|
||||
name: targetTeam.name,
|
||||
memberIds: [
|
||||
...targetTeam.memberIds,
|
||||
member.memberId ?? '',
|
||||
],
|
||||
description: targetTeam.description,
|
||||
);
|
||||
});
|
||||
}
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('이동'),
|
||||
),
|
||||
],
|
||||
actions: DialogActions.confirm(
|
||||
onCancel: () => Navigator.of(context).pop(),
|
||||
onConfirm: () {
|
||||
if (targetTeamIndex != teamIndex) {
|
||||
setState(() {
|
||||
_generatedTeams[teamIndex] = Team(
|
||||
id: team.id,
|
||||
eventId: team.eventId,
|
||||
name: team.name,
|
||||
memberIds: team.memberIds
|
||||
.where((id) => id != member.memberId)
|
||||
.toList(),
|
||||
description: team.description,
|
||||
);
|
||||
|
||||
final targetTeam = _generatedTeams[targetTeamIndex];
|
||||
_generatedTeams[targetTeamIndex] = Team(
|
||||
id: targetTeam.id,
|
||||
eventId: targetTeam.eventId,
|
||||
name: targetTeam.name,
|
||||
memberIds: [
|
||||
...targetTeam.memberIds,
|
||||
member.memberId,
|
||||
],
|
||||
description: targetTeam.description,
|
||||
);
|
||||
});
|
||||
}
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
confirmText: '이동',
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -40,7 +40,8 @@ class _ClubStatisticsScreenState extends State<ClubStatisticsScreen> {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
// await 이전에 messenger 캡처하여 안전하게 사용
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
try {
|
||||
final authService = Provider.of<AuthService>(context, listen: false);
|
||||
final clubService = Provider.of<ClubService>(context, listen: false);
|
||||
@@ -73,9 +74,9 @@ class _ClubStatisticsScreenState extends State<ClubStatisticsScreen> {
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('데이터 로드 중 오류가 발생했습니다: $e')));
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('데이터 로드 중 오류가 발생했습니다: $e')),
|
||||
);
|
||||
} finally {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
|
||||
@@ -10,6 +10,7 @@ import '../../services/score_service.dart';
|
||||
import '../../models/member_model.dart';
|
||||
import '../../models/score_model.dart';
|
||||
import 'score_entry_screen.dart';
|
||||
import '../../widgets/dialog_actions.dart';
|
||||
|
||||
class MemberStatisticsScreen extends StatefulWidget {
|
||||
final String memberId;
|
||||
@@ -56,12 +57,15 @@ class _MemberStatisticsScreenState extends State<MemberStatisticsScreen>
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
// await 이전에 messenger 캡처
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
|
||||
try {
|
||||
final authService = Provider.of<AuthService>(context, listen: false);
|
||||
final clubService = Provider.of<ClubService>(context, listen: false);
|
||||
final memberService = Provider.of<MemberService>(context, listen: false);
|
||||
final scoreService = Provider.of<ScoreService>(context, listen: false);
|
||||
|
||||
final clubService = Provider.of<ClubService>(context, listen: false);
|
||||
|
||||
if (clubService.currentClub != null) {
|
||||
// 회원 정보 로드
|
||||
_member = await memberService.fetchMemberById(widget.memberId);
|
||||
@@ -80,13 +84,15 @@ class _MemberStatisticsScreenState extends State<MemberStatisticsScreen>
|
||||
]);
|
||||
}
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('데이터 로드 중 오류가 발생했습니다: $e')));
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('데이터 로드 중 오류가 발생했습니다: $e')),
|
||||
);
|
||||
} finally {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -608,7 +614,7 @@ class _MemberStatisticsScreenState extends State<MemberStatisticsScreen>
|
||||
dotData: const FlDotData(show: true),
|
||||
belowBarData: BarAreaData(
|
||||
show: true,
|
||||
color: Colors.blue.withOpacity(0.2),
|
||||
color: Colors.blue.withValues(alpha: 0.2),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -619,43 +625,28 @@ class _MemberStatisticsScreenState extends State<MemberStatisticsScreen>
|
||||
void _showDeleteConfirmationDialog(Score score) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('점수 삭제'),
|
||||
content: const Text('이 점수 기록을 정말 삭제하시겠습니까?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
Navigator.of(context).pop();
|
||||
|
||||
try {
|
||||
final scoreService = Provider.of<ScoreService>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
await scoreService.deleteScore(score.id);
|
||||
|
||||
// 데이터 새로고침
|
||||
_loadMemberData();
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('점수가 삭제되었습니다')));
|
||||
}
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('점수 삭제에 실패했습니다: $e')));
|
||||
}
|
||||
},
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
child: const Text('삭제'),
|
||||
),
|
||||
],
|
||||
actions: DialogActions.confirm(
|
||||
onCancel: () => Navigator.of(dialogContext).pop(),
|
||||
onConfirm: () async {
|
||||
// await 전에 필요한 객체 캡처
|
||||
final navigator = Navigator.of(dialogContext);
|
||||
final messenger = ScaffoldMessenger.of(dialogContext);
|
||||
final scoreService = Provider.of<ScoreService>(dialogContext, listen: false);
|
||||
navigator.pop();
|
||||
try {
|
||||
await scoreService.deleteScore(score.id);
|
||||
_loadMemberData();
|
||||
messenger.showSnackBar(const SnackBar(content: Text('점수가 삭제되었습니다')));
|
||||
} catch (e) {
|
||||
messenger.showSnackBar(SnackBar(content: Text('점수 삭제에 실패했습니다: $e')));
|
||||
}
|
||||
},
|
||||
destructive: true,
|
||||
confirmText: '삭제',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -65,21 +65,16 @@ class _ScoreEntryScreenState extends State<ScoreEntryScreen> {
|
||||
|
||||
// 점수 저장
|
||||
Future<void> _saveScore() async {
|
||||
if (!_formKey.currentState!.validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_selectedMemberId == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('회원을 선택해주세요')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
|
||||
// await 전에 필요한 파생 객체 캡처 (catch에서도 사용 가능하도록 try 밖)
|
||||
final navigator = Navigator.of(context);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
|
||||
try {
|
||||
final scoreService = Provider.of<ScoreService>(context, listen: false);
|
||||
final clubService = Provider.of<ClubService>(context, listen: false);
|
||||
@@ -106,22 +101,23 @@ class _ScoreEntryScreenState extends State<ScoreEntryScreen> {
|
||||
|
||||
// 점수 저장
|
||||
await scoreService.addScore(scoreData);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('점수가 저장되었습니다')),
|
||||
);
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
// 성공 알림 및 화면 닫기
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(content: Text('점수가 저장되었습니다')),
|
||||
);
|
||||
navigator.pop();
|
||||
}
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
// 에러 알림 (사전 캡처한 messenger 사용)
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('점수 저장에 실패했습니다: $e')),
|
||||
);
|
||||
} finally {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,15 +193,21 @@ class _ScoreEntryScreenState extends State<ScoreEntryScreen> {
|
||||
final members = memberService.members;
|
||||
|
||||
return DropdownButtonFormField<String>(
|
||||
key: const Key('score_entry_member_dropdown'),
|
||||
decoration: const InputDecoration(
|
||||
labelText: '회원 선택',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
value: _selectedMemberId,
|
||||
isExpanded: true,
|
||||
items: members.map((member) {
|
||||
return DropdownMenuItem<String>(
|
||||
value: member.id,
|
||||
child: Text(member.name),
|
||||
child: Text(
|
||||
member.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (value) {
|
||||
@@ -230,11 +232,13 @@ class _ScoreEntryScreenState extends State<ScoreEntryScreen> {
|
||||
final events = eventService.events;
|
||||
|
||||
return DropdownButtonFormField<String>(
|
||||
key: const Key('score_entry_event_dropdown'),
|
||||
decoration: const InputDecoration(
|
||||
labelText: '이벤트 선택 (선택사항)',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
value: _selectedEventId,
|
||||
isExpanded: true,
|
||||
items: [
|
||||
const DropdownMenuItem<String>(
|
||||
value: null,
|
||||
@@ -243,7 +247,11 @@ class _ScoreEntryScreenState extends State<ScoreEntryScreen> {
|
||||
...events.map((event) {
|
||||
return DropdownMenuItem<String>(
|
||||
value: event.id,
|
||||
child: Text(event.title),
|
||||
child: Text(
|
||||
event.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -136,7 +136,7 @@ class _SubscriptionDetailsScreenState extends State<SubscriptionDetailsScreen> {
|
||||
color: Colors.white,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
color: Colors.black.withValues(alpha: 0.1),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, -5),
|
||||
),
|
||||
@@ -207,7 +207,7 @@ class _SubscriptionDetailsScreenState extends State<SubscriptionDetailsScreen> {
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.amber.withOpacity(0.1),
|
||||
color: Colors.amber.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.amber),
|
||||
),
|
||||
@@ -349,6 +349,8 @@ class _SubscriptionDetailsScreenState extends State<SubscriptionDetailsScreen> {
|
||||
}
|
||||
|
||||
Future<void> _subscribe(BuildContext context) async {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final navigator = Navigator.of(context);
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
@@ -361,12 +363,12 @@ class _SubscriptionDetailsScreenState extends State<SubscriptionDetailsScreen> {
|
||||
);
|
||||
|
||||
if (success && mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(content: Text('구독이 성공적으로 완료되었습니다')),
|
||||
);
|
||||
Navigator.of(context).pop(); // 상세 화면 닫기
|
||||
navigator.pop(); // 상세 화면 닫기
|
||||
} else if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(subscriptionService.error ?? '구독 처리 중 오류가 발생했습니다'),
|
||||
backgroundColor: Colors.red,
|
||||
@@ -375,7 +377,7 @@ class _SubscriptionDetailsScreenState extends State<SubscriptionDetailsScreen> {
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('오류: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
|
||||
import '../../models/subscription_model.dart';
|
||||
import '../../services/subscription_service.dart';
|
||||
import 'subscription_details_screen.dart';
|
||||
import '../../widgets/dialog_actions.dart';
|
||||
|
||||
class SubscriptionScreen extends StatefulWidget {
|
||||
const SubscriptionScreen({super.key});
|
||||
@@ -24,7 +25,7 @@ class _SubscriptionScreenState extends State<SubscriptionScreen> {
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
subscriptionService.fetchCurrentSubscription();
|
||||
subscriptionService.loadCurrentSubscription();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -49,7 +50,7 @@ class _SubscriptionScreenState extends State<SubscriptionScreen> {
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: () =>
|
||||
subscriptionService.fetchCurrentSubscription(),
|
||||
subscriptionService.loadCurrentSubscription(),
|
||||
child: const Text('다시 시도'),
|
||||
),
|
||||
],
|
||||
@@ -121,8 +122,8 @@ class _SubscriptionScreenState extends State<SubscriptionScreen> {
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: subscription.isActive
|
||||
? Colors.green.withOpacity(0.1)
|
||||
: Colors.grey.withOpacity(0.1),
|
||||
? Colors.green.withValues(alpha: 0.1)
|
||||
: Colors.grey.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: subscription.isActive
|
||||
@@ -222,7 +223,7 @@ class _SubscriptionScreenState extends State<SubscriptionScreen> {
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
// 플랜 선택 화면으로 전환
|
||||
subscriptionService.fetchCurrentSubscription();
|
||||
subscriptionService.loadCurrentSubscription();
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.upgrade),
|
||||
@@ -271,7 +272,7 @@ class _SubscriptionScreenState extends State<SubscriptionScreen> {
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green.withOpacity(0.1),
|
||||
color: Colors.green.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.green),
|
||||
),
|
||||
@@ -340,7 +341,7 @@ class _SubscriptionScreenState extends State<SubscriptionScreen> {
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.amber.withOpacity(0.1),
|
||||
color: Colors.amber.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: Colors.amber),
|
||||
),
|
||||
@@ -441,42 +442,40 @@ class _SubscriptionScreenState extends State<SubscriptionScreen> {
|
||||
}
|
||||
|
||||
void _showCancelDialog(SubscriptionService subscriptionService) {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final navigator = Navigator.of(context);
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('구독 취소'),
|
||||
content: const Text(
|
||||
'정말로 구독을 취소하시겠습니까?\n'
|
||||
'취소하면 현재 구독 기간이 끝날 때까지만 서비스를 이용할 수 있습니다.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('아니오'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
Navigator.of(context).pop();
|
||||
final success = await subscriptionService.cancelSubscription();
|
||||
if (success && mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('구독이 취소되었습니다')));
|
||||
}
|
||||
},
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
child: const Text('예, 취소합니다'),
|
||||
),
|
||||
],
|
||||
actions: DialogActions.confirm(
|
||||
onCancel: () => Navigator.of(dialogContext).pop(),
|
||||
onConfirm: () async {
|
||||
navigator.pop();
|
||||
final success = await subscriptionService.cancelSubscription();
|
||||
if (success && mounted) {
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(content: Text('구독이 취소되었습니다')),
|
||||
);
|
||||
}
|
||||
},
|
||||
destructive: true,
|
||||
confirmText: '예, 취소합니다',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _toggleAutoRenew(SubscriptionService subscriptionService) async {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final success = await subscriptionService.toggleAutoRenew();
|
||||
if (success && mounted) {
|
||||
final isAutoRenew = subscriptionService.currentSubscription!.autoRenew;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(isAutoRenew ? '자동 갱신이 활성화되었습니다' : '자동 갱신이 비활성화되었습니다'),
|
||||
),
|
||||
|
||||
@@ -14,7 +14,7 @@ class ApiService {
|
||||
|
||||
late Dio _dio;
|
||||
final CookieJar _cookieJar = CookieJar();
|
||||
String? _token;
|
||||
|
||||
|
||||
ApiService._internal() {
|
||||
_dio = Dio(BaseOptions(
|
||||
@@ -36,9 +36,14 @@ class ApiService {
|
||||
));
|
||||
}
|
||||
|
||||
// 테스트를 위한 Dio 주입 지점
|
||||
@visibleForTesting
|
||||
void setDio(Dio dio) {
|
||||
_dio = dio;
|
||||
}
|
||||
|
||||
// 토큰 설정
|
||||
void setToken(String token) {
|
||||
_token = token;
|
||||
_dio.options.headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
|
||||
|
||||
@@ -7,12 +7,17 @@ import '../models/user_model.dart';
|
||||
import './api_service.dart';
|
||||
|
||||
class AuthService with ChangeNotifier {
|
||||
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
|
||||
// 주입 가능하도록 생성자에서 설정, 기본값 유지
|
||||
final FlutterSecureStorage _secureStorage;
|
||||
final ApiService _apiService;
|
||||
User? _currentUser;
|
||||
String? _token;
|
||||
bool _isLoading = false;
|
||||
bool _isInitialized = false;
|
||||
final ApiService _apiService = ApiService();
|
||||
|
||||
AuthService({ApiService? apiService, FlutterSecureStorage? secureStorage})
|
||||
: _apiService = apiService ?? ApiService(),
|
||||
_secureStorage = secureStorage ?? const FlutterSecureStorage();
|
||||
|
||||
User? get currentUser => _currentUser;
|
||||
String? get token => _token;
|
||||
|
||||
@@ -13,6 +13,7 @@ class ClubService with ChangeNotifier {
|
||||
bool _isLoading = false;
|
||||
String? _token;
|
||||
ApiService _apiService;
|
||||
bool _disposed = false;
|
||||
|
||||
// 기본 생성자
|
||||
ClubService() : _apiService = ApiService();
|
||||
@@ -26,6 +27,7 @@ class ClubService with ChangeNotifier {
|
||||
|
||||
// 초기화 함수
|
||||
Future<void> initialize(String token) async {
|
||||
if (_disposed) return;
|
||||
_token = token;
|
||||
_apiService.setToken(token);
|
||||
|
||||
@@ -34,16 +36,17 @@ class ClubService with ChangeNotifier {
|
||||
final clubId = prefs.getString(ApiConfig.clubIdKey);
|
||||
|
||||
if (clubId != null) {
|
||||
if (_disposed) return;
|
||||
await fetchClubById(clubId);
|
||||
}
|
||||
}
|
||||
|
||||
// 사용자의 모든 클럽 가져오기
|
||||
Future<void> fetchUserClubs() async {
|
||||
if (_token == null) return;
|
||||
if (_disposed || _token == null) return;
|
||||
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
if (!_disposed) notifyListeners();
|
||||
|
||||
try {
|
||||
final data = await _apiService.post('${ApiConfig.clubs}/user');
|
||||
@@ -53,24 +56,24 @@ class ClubService with ChangeNotifier {
|
||||
_clubs = clubsData.map((clubData) => Club.fromJson(clubData)).toList();
|
||||
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
if (!_disposed) notifyListeners();
|
||||
} catch (e) {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
if (!_disposed) notifyListeners();
|
||||
throw Exception('클럽 목록을 불러오는데 실패했습니다: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// ID로 클럽 정보 가져오기
|
||||
Future<void> fetchClubById(String clubId) async {
|
||||
if (_token == null) return;
|
||||
if (_disposed || _token == null) return;
|
||||
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
if (!_disposed) notifyListeners();
|
||||
|
||||
try {
|
||||
final data = await _apiService.post(
|
||||
'${ApiConfig.clubs}',
|
||||
ApiConfig.clubs,
|
||||
data: {'clubId': clubId},
|
||||
);
|
||||
|
||||
@@ -82,10 +85,10 @@ class ClubService with ChangeNotifier {
|
||||
await prefs.setString(ApiConfig.clubIdKey, clubId);
|
||||
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
if (!_disposed) notifyListeners();
|
||||
} catch (e) {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
if (!_disposed) notifyListeners();
|
||||
throw Exception('클럽 정보를 불러오는데 실패했습니다: $e');
|
||||
}
|
||||
}
|
||||
@@ -97,10 +100,10 @@ class ClubService with ChangeNotifier {
|
||||
|
||||
// 백엔드에 클럽 선택 요청 (세션에 clubId 저장)
|
||||
Future<void> selectClub(String clubId) async {
|
||||
if (_token == null) return;
|
||||
if (_disposed || _token == null) return;
|
||||
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
if (!_disposed) notifyListeners();
|
||||
|
||||
try {
|
||||
await _apiService.post(
|
||||
@@ -109,6 +112,7 @@ class ClubService with ChangeNotifier {
|
||||
);
|
||||
|
||||
// 클럽 선택 성공 후 클럽 정보 가져오기
|
||||
if (_disposed) return;
|
||||
await fetchClubById(clubId);
|
||||
|
||||
// 클럽 ID를 로컬에 저장
|
||||
@@ -121,13 +125,15 @@ class ClubService with ChangeNotifier {
|
||||
}
|
||||
|
||||
// 클럽 변경 이벤트 발생 - 다른 서비스들에게 알림
|
||||
EventBus().fire(ClubChangedEvent(clubId));
|
||||
if (!_disposed) {
|
||||
EventBus().fire(ClubChangedEvent(clubId));
|
||||
}
|
||||
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
if (!_disposed) notifyListeners();
|
||||
} catch (e) {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
if (!_disposed) notifyListeners();
|
||||
throw Exception('클럽 선택에 실패했습니다: $e');
|
||||
}
|
||||
}
|
||||
@@ -139,11 +145,11 @@ class ClubService with ChangeNotifier {
|
||||
}
|
||||
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
if (!_disposed) notifyListeners();
|
||||
|
||||
try {
|
||||
final data = await _apiService.post(
|
||||
'${ApiConfig.clubs}',
|
||||
ApiConfig.clubs,
|
||||
data: club.toJson(),
|
||||
);
|
||||
|
||||
@@ -157,24 +163,24 @@ class ClubService with ChangeNotifier {
|
||||
await prefs.setString(ApiConfig.clubIdKey, newClub.id);
|
||||
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
if (!_disposed) notifyListeners();
|
||||
|
||||
return newClub;
|
||||
} catch (e) {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
if (!_disposed) notifyListeners();
|
||||
throw Exception('클럽 생성에 실패했습니다: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// 클럽 정보 업데이트
|
||||
Future<Club> updateClub(String clubId, Map<String, dynamic> updates) async {
|
||||
if (_token == null) {
|
||||
if (_disposed || _token == null) {
|
||||
throw Exception('인증이 필요합니다');
|
||||
}
|
||||
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
if (!_disposed) notifyListeners();
|
||||
|
||||
try {
|
||||
final data = await _apiService.put(
|
||||
@@ -202,19 +208,19 @@ class ClubService with ChangeNotifier {
|
||||
}
|
||||
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
if (!_disposed) notifyListeners();
|
||||
|
||||
return updatedClub;
|
||||
} catch (e) {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
if (!_disposed) notifyListeners();
|
||||
throw Exception('클럽 정보 업데이트에 실패했습니다: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// 클럽 정보 가져오기 (ID로)
|
||||
Future<Club> fetchClub(String clubId) async {
|
||||
if (_token == null) {
|
||||
if (_disposed || _token == null) {
|
||||
throw Exception('인증이 필요합니다');
|
||||
}
|
||||
|
||||
@@ -225,7 +231,7 @@ class ClubService with ChangeNotifier {
|
||||
}
|
||||
|
||||
final data = await _apiService.post(
|
||||
'${ApiConfig.clubs}',
|
||||
ApiConfig.clubs,
|
||||
data: {'clubId': clubId},
|
||||
);
|
||||
|
||||
@@ -235,4 +241,10 @@ class ClubService with ChangeNotifier {
|
||||
throw Exception('클럽 정보를 불러오는데 실패했습니다: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,7 +187,7 @@ class EventBus {
|
||||
if (kDebugMode) {
|
||||
debugPrint('EventBus: 인스턴스 정리 중 - ${entry.key}');
|
||||
}
|
||||
entry.value.dispose(); // 완전히 dispose 호출
|
||||
await entry.value.dispose(); // 완전히 dispose 호출
|
||||
|
||||
// 각 인스턴스 dispose 후 마이크로태스크 실행으로 비동기 작업 완료 보장
|
||||
await Future.microtask(() {});
|
||||
@@ -344,7 +344,7 @@ class EventBus {
|
||||
// 테스트 ID가 있지만 인스턴스가 없는 경우 생성
|
||||
if (!_testInstances.containsKey(_currentTestId!)) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('EventBus: 테스트 ID ${_currentTestId}에 대한 새 인스턴스 생성');
|
||||
debugPrint('EventBus: 테스트 ID $_currentTestId에 대한 새 인스턴스 생성');
|
||||
}
|
||||
_testInstances[_currentTestId!] = EventBus._internal();
|
||||
_testInstances[_currentTestId!]!.reset();
|
||||
|
||||
@@ -16,12 +16,12 @@ class EventService with ChangeNotifier {
|
||||
List<Team> _teams = [];
|
||||
bool _isLoading = false;
|
||||
String? _token;
|
||||
ApiService _apiService;
|
||||
final ApiService _apiService;
|
||||
String? _clubId;
|
||||
|
||||
|
||||
// 기본 생성자
|
||||
EventService() : _apiService = ApiService();
|
||||
|
||||
|
||||
// 테스트용 생성자
|
||||
EventService.forTest(this._apiService);
|
||||
|
||||
@@ -210,12 +210,23 @@ class EventService with ChangeNotifier {
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
// userId는 일부 라우트에서 필수이므로 prefs에서 읽어 포함
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final userId = prefs.getString(ApiConfig.userIdKey);
|
||||
final payload = {
|
||||
'clubId': _clubId,
|
||||
if (userId != null) 'userId': userId,
|
||||
'eventId': eventId,
|
||||
};
|
||||
final data = await _apiService.post(
|
||||
'${ApiConfig.clubs}/events/participants',
|
||||
data: {'eventId': eventId},
|
||||
'${ApiConfig.clubs}/events/$eventId/participants/list',
|
||||
data: payload,
|
||||
);
|
||||
|
||||
final List<dynamic> participantsData = data['participants'] ?? [];
|
||||
// 응답이 리스트이거나 객체 내 participants 키로 올 수 있어 모두 처리
|
||||
final List<dynamic> participantsData = (data is List)
|
||||
? data
|
||||
: (data['participants'] ?? []);
|
||||
_participants = participantsData
|
||||
.map((participantData) => Participant.fromJson(participantData))
|
||||
.toList();
|
||||
@@ -244,9 +255,12 @@ class EventService with ChangeNotifier {
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
// 참가자 페이로드 정규화: 상태/결제 상태 표준화 및 빈 문자열 null 처리
|
||||
final normalized = _sanitizeParticipantPayload(participantData);
|
||||
final payload = {'clubId': _clubId, ...normalized};
|
||||
final data = await _apiService.post(
|
||||
'${ApiConfig.clubs}/events/$eventId/participants',
|
||||
data: participantData,
|
||||
data: payload,
|
||||
);
|
||||
|
||||
final newParticipant = Participant.fromJson(data['participant']);
|
||||
@@ -278,9 +292,11 @@ class EventService with ChangeNotifier {
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
// 업데이트 페이로드도 동일한 규칙으로 정규화
|
||||
final normalized = _sanitizeParticipantPayload(updates);
|
||||
final data = await _apiService.put(
|
||||
'${ApiConfig.clubs}/events/$eventId/participants/$participantId',
|
||||
data: updates,
|
||||
data: normalized,
|
||||
);
|
||||
|
||||
final updatedParticipant = Participant.fromJson(data['participant']);
|
||||
@@ -304,6 +320,52 @@ class EventService with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
// 참가자 페이로드 정규화 헬퍼
|
||||
Map<String, dynamic> _sanitizeParticipantPayload(Map<String, dynamic> data) {
|
||||
final Map<String, dynamic> out = Map<String, dynamic>.from(data);
|
||||
|
||||
// 상태 표준화: 구버전/철자 변형 호환
|
||||
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)) {
|
||||
// 허용 외 값은 안전하게 pending 처리
|
||||
status = 'pending';
|
||||
}
|
||||
out['status'] = status;
|
||||
}
|
||||
|
||||
// 결제 상태 표준화: isPaid -> paymentStatus 변환 및 강제
|
||||
if (out.containsKey('isPaid') &&
|
||||
(out['paymentStatus'] == null ||
|
||||
(out['paymentStatus'].toString().isEmpty))) {
|
||||
final isPaidVal = out['isPaid'] == true;
|
||||
out['paymentStatus'] = isPaidVal ? 'paid' : 'unpaid';
|
||||
}
|
||||
final payRaw = (out['paymentStatus']?.toString().trim() ?? '')
|
||||
.toLowerCase();
|
||||
if (payRaw.isNotEmpty) {
|
||||
out['paymentStatus'] = (payRaw == 'paid') ? 'paid' : 'unpaid';
|
||||
}
|
||||
// 서버 스키마에 맞추기 위해 isPaid 키는 제거 (서버는 paymentStatus만 사용)
|
||||
out.remove('isPaid');
|
||||
|
||||
// 빈 문자열을 명시적 null로 변환 (이름/이메일/전화/메모 등)
|
||||
out.updateAll((key, value) {
|
||||
if (value is String && value.trim().isEmpty) return null;
|
||||
return value;
|
||||
});
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
// 참가자 삭제
|
||||
Future<bool> removeParticipant(String eventId, String participantId) async {
|
||||
if (_token == null || _clubId == null) {
|
||||
@@ -485,9 +547,7 @@ class EventService with ChangeNotifier {
|
||||
);
|
||||
|
||||
final List<dynamic> teamsData = data['teams'] ?? [];
|
||||
_teams = teamsData
|
||||
.map((teamData) => Team.fromJson(teamData))
|
||||
.toList();
|
||||
_teams = teamsData.map((teamData) => Team.fromJson(teamData)).toList();
|
||||
|
||||
notifyListeners();
|
||||
return _teams;
|
||||
@@ -495,7 +555,7 @@ class EventService with ChangeNotifier {
|
||||
throw Exception('팀 목록을 불러오는데 실패했습니다: $e');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 이벤트에 팀이 있는지 확인 (간략 조회)
|
||||
Future<bool> hasEventTeams(String eventId) async {
|
||||
if (_token == null) {
|
||||
@@ -622,7 +682,7 @@ class EventService with ChangeNotifier {
|
||||
throw Exception('파일에서 이벤트 생성에 실패했습니다: $e');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 이벤트 복제
|
||||
Future<Event> cloneEvent(String eventId, {String? newName}) async {
|
||||
if (_token == null || _clubId == null) {
|
||||
@@ -635,7 +695,7 @@ class EventService with ChangeNotifier {
|
||||
try {
|
||||
// 원본 이벤트 정보 가져오기
|
||||
final originalEvent = await fetchEventById(eventId);
|
||||
|
||||
|
||||
// 복제할 이벤트 데이터 준비
|
||||
final Map<String, dynamic> cloneData = {
|
||||
'clubId': _clubId,
|
||||
|
||||
@@ -5,7 +5,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:in_app_purchase/in_app_purchase.dart';
|
||||
import 'package:in_app_purchase_android/in_app_purchase_android.dart';
|
||||
import 'package:in_app_purchase_storekit/in_app_purchase_storekit.dart';
|
||||
|
||||
|
||||
import '../config/api_config.dart';
|
||||
import '../models/subscription_model.dart';
|
||||
@@ -23,7 +23,6 @@ class InAppPurchaseService extends ChangeNotifier {
|
||||
|
||||
// 구매 검증 관련 변수
|
||||
bool _purchaseVerified = false;
|
||||
Map<String, dynamic>? _verifiedPurchase;
|
||||
|
||||
// 구독 상품 ID (스토어에 등록된 ID와 일치해야 함)
|
||||
final Map<SubscriptionPlanType, String> _subscriptionIds = {
|
||||
@@ -241,14 +240,9 @@ class InAppPurchaseService extends ChangeNotifier {
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
// 검증 성공
|
||||
final data = jsonDecode(response.body);
|
||||
jsonDecode(response.body);
|
||||
// 성공 이벤트 발생 (SubscriptionService에서 구독 정보 업데이트)
|
||||
_purchaseVerified = true;
|
||||
_verifiedPurchase = {
|
||||
'transactionId': transactionId,
|
||||
'productId': productId,
|
||||
'verificationData': data,
|
||||
};
|
||||
notifyListeners();
|
||||
return true;
|
||||
} else {
|
||||
|
||||
@@ -9,12 +9,14 @@ import './event_bus.dart';
|
||||
|
||||
class MemberService with ChangeNotifier {
|
||||
List<Member> _members = [];
|
||||
Member? _currentMember;
|
||||
bool _isLoading = false;
|
||||
String? _token;
|
||||
String? _clubId;
|
||||
final ApiService _apiService;
|
||||
StreamSubscription? _eventSubscription;
|
||||
String? _lastError;
|
||||
DateTime? _lastLoadedAt;
|
||||
bool _disposed = false;
|
||||
|
||||
// 기본 생성자
|
||||
MemberService() : _apiService = ApiService();
|
||||
@@ -24,6 +26,8 @@ class MemberService with ChangeNotifier {
|
||||
|
||||
List<Member> get members => [..._members];
|
||||
bool get isLoading => _isLoading;
|
||||
String? get lastError => _lastError;
|
||||
DateTime? get lastLoadedAt => _lastLoadedAt;
|
||||
|
||||
// 초기화 함수
|
||||
void initialize(String token) {
|
||||
@@ -33,80 +37,52 @@ class MemberService with ChangeNotifier {
|
||||
// 이벤트 구독 - 기존 구독이 있으면 취소 후 다시 구독
|
||||
_eventSubscription?.cancel();
|
||||
_eventSubscription = EventBus().on<ClubChangedEvent>().listen((event) {
|
||||
if (_disposed) return; // dispose 이후 방어
|
||||
if (event.clubId.isNotEmpty) {
|
||||
setClubId(event.clubId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 비동기 리소스 상태 로깅
|
||||
Future<void> _logResourceState(String context) async {
|
||||
if (kDebugMode) {
|
||||
debugPrint('===== MemberService 비동기 리소스 상태 ($context) =====');
|
||||
debugPrint('시간: ${DateTime.now().toIso8601String()}');
|
||||
debugPrint('구독 상태: ${_eventSubscription != null ? '활성' : '비활성'}');
|
||||
debugPrint('클럽 ID: $_clubId');
|
||||
debugPrint('토큰 상태: ${_token != null ? '있음' : '없음'}');
|
||||
debugPrint('로딩 상태: $_isLoading');
|
||||
debugPrint('회원 수: ${_members.length}');
|
||||
debugPrint('EventBus 활성 구독 수: ${EventBus.activeSubscriptionCount}');
|
||||
debugPrint('===========================================');
|
||||
}
|
||||
}
|
||||
// (개발 중 로깅 유틸 제거됨)
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {
|
||||
void dispose() {
|
||||
// dispose 시작 상태 로깅
|
||||
if (kDebugMode) {
|
||||
debugPrint('MemberService: dispose 시작');
|
||||
}
|
||||
await _logResourceState('dispose 시작');
|
||||
|
||||
// 이벤트 구독 취소 확실히 처리
|
||||
// 비동기 상태 로깅은 테스트/런타임 안정성을 위해 동기 경로로 축소
|
||||
// (필요 시 개발 중 수동 호출)
|
||||
|
||||
// 이벤트 구독 취소: 동기 dispose 규약을 지키기 위해 unawaited로 처리
|
||||
_disposed = true; // 이후 작업 차단
|
||||
if (_eventSubscription != null) {
|
||||
try {
|
||||
// 구독 취소 전 로깅
|
||||
if (kDebugMode) {
|
||||
debugPrint('MemberService: 구독 취소 시작');
|
||||
}
|
||||
|
||||
// 구독 취소
|
||||
_eventSubscription!.cancel();
|
||||
|
||||
// 마이크로태스크 실행으로 비동기 작업 완료 보장
|
||||
await Future.microtask(() {});
|
||||
|
||||
// 구독 취소 후 로깅
|
||||
if (kDebugMode) {
|
||||
debugPrint('MemberService: 구독 취소 완료');
|
||||
}
|
||||
|
||||
// 구독 참조 제거
|
||||
unawaited(_eventSubscription!.cancel());
|
||||
_eventSubscription = null;
|
||||
if (kDebugMode) {
|
||||
debugPrint('MemberService: 구독 취소 요청');
|
||||
}
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('MemberService: 구독 취소 중 오류 - $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 추가 마이크로태스크 실행으로 비동기 작업 완료 보장 (타이머 생성 회피)
|
||||
await Future.microtask(() {});
|
||||
await Future.microtask(() {});
|
||||
|
||||
// dispose 완료 후 상태 로깅
|
||||
await _logResourceState('dispose 완료');
|
||||
|
||||
// 구독 수 로깅 - 지연 없이 즉시 처리
|
||||
|
||||
if (kDebugMode) {
|
||||
debugPrint('MemberService: dispose 완료 후 구독 수 - ${EventBus.activeSubscriptionCount}');
|
||||
debugPrint('MemberService: dispose 종료');
|
||||
}
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// 클럽 ID 설정
|
||||
Future<void> setClubId(String clubId) async {
|
||||
if (_disposed) return; // dispose 이후 무시
|
||||
// 클럽 ID가 변경된 경우에만 처리
|
||||
if (_clubId != clubId) {
|
||||
_clubId = clubId;
|
||||
@@ -126,9 +102,14 @@ class MemberService with ChangeNotifier {
|
||||
|
||||
// 클럽의 모든 회원 가져오기
|
||||
Future<void> fetchClubMembers() async {
|
||||
print('fetchClubMembers 호출됨: token=${_token}');
|
||||
if (_disposed) return; // dispose 이후 무시
|
||||
final sw = Stopwatch()..start();
|
||||
if (kDebugMode) {
|
||||
debugPrint('MemberService.fetchClubMembers: 시작 (token=${_token != null}, clubId=$_clubId)');
|
||||
}
|
||||
|
||||
_isLoading = true;
|
||||
_lastError = null;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
@@ -142,6 +123,9 @@ class MemberService with ChangeNotifier {
|
||||
}
|
||||
|
||||
if (clubId == null) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('MemberService.fetchClubMembers: clubId 없음 - SharedPreferences에도 없음');
|
||||
}
|
||||
throw Exception('선택된 클럽이 없습니다');
|
||||
}
|
||||
|
||||
@@ -150,7 +134,9 @@ class MemberService with ChangeNotifier {
|
||||
data: {'clubId': clubId},
|
||||
);
|
||||
|
||||
print('회원 응답 데이터: $data');
|
||||
if (kDebugMode) {
|
||||
debugPrint('MemberService.fetchClubMembers: 응답 수신 (elapsed=${sw.elapsedMilliseconds}ms)');
|
||||
}
|
||||
|
||||
// 응답 데이터가 리스트 형태로 직접 왔으므로 그대로 사용
|
||||
if (data is List) {
|
||||
@@ -158,21 +144,40 @@ class MemberService with ChangeNotifier {
|
||||
_members = membersData
|
||||
.map((memberData) => Member.fromJson(memberData))
|
||||
.toList();
|
||||
print('변환된 회원 리스트 길이: ${_members.length}');
|
||||
if (kDebugMode) {
|
||||
debugPrint('MemberService.fetchClubMembers: 리스트 파싱 완료 (count=${_members.length})');
|
||||
if (_members.isEmpty) {
|
||||
debugPrint('MemberService.fetchClubMembers: 회원 목록이 비어있음');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 응답 데이터가 객체 형태로 왔을 경우 members 키를 통해 접근
|
||||
final List<dynamic> membersData = data['members'] ?? [];
|
||||
_members = membersData
|
||||
.map((memberData) => Member.fromJson(memberData))
|
||||
.toList();
|
||||
print('변환된 회원 리스트 길이: ${_members.length}');
|
||||
if (kDebugMode) {
|
||||
debugPrint('MemberService.fetchClubMembers: 객체 파싱 완료 (count=${_members.length})');
|
||||
if (_members.isEmpty) {
|
||||
debugPrint('MemberService.fetchClubMembers: 회원 목록이 비어있음');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_isLoading = false;
|
||||
_lastLoadedAt = DateTime.now();
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('MemberService.fetchClubMembers: 성공 (total=${_members.length}, elapsed=${sw.elapsedMilliseconds}ms, loadedAt=${_lastLoadedAt?.toIso8601String()})');
|
||||
}
|
||||
} catch (e, st) {
|
||||
_isLoading = false;
|
||||
_lastError = e.toString();
|
||||
notifyListeners();
|
||||
if (kDebugMode) {
|
||||
debugPrint('MemberService.fetchClubMembers: 실패 (${e.toString()})');
|
||||
debugPrint('MemberService.fetchClubMembers: stacktrace -> $st');
|
||||
}
|
||||
throw Exception('회원 목록을 불러오는데 실패했습니다: $e');
|
||||
}
|
||||
}
|
||||
@@ -210,9 +215,13 @@ class MemberService with ChangeNotifier {
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final payload = {
|
||||
'clubId': _clubId,
|
||||
...memberData,
|
||||
};
|
||||
final data = await _apiService.post(
|
||||
'${ApiConfig.clubs}/members/add',
|
||||
data: memberData,
|
||||
data: payload,
|
||||
);
|
||||
|
||||
// 백엔드 응답 처리: 직접 회원 객체가 반환되거나 {member: {...}} 형태로 반환될 수 있음
|
||||
|
||||
@@ -13,6 +13,8 @@ class ScoreService with ChangeNotifier {
|
||||
ApiService _apiService = ApiService();
|
||||
String? _memberId;
|
||||
String? _eventId;
|
||||
DateTime? _lastLoadedAt;
|
||||
String? _lastError;
|
||||
|
||||
// 테스트용 생성자
|
||||
ScoreService.forTest(ApiService apiService, {String? clubId, String? memberId, String? eventId}) {
|
||||
@@ -37,6 +39,8 @@ class ScoreService with ChangeNotifier {
|
||||
|
||||
List<Score> get scores => [..._scores];
|
||||
bool get isLoading => _isLoading;
|
||||
DateTime? get lastLoadedAt => _lastLoadedAt;
|
||||
String? get lastError => _lastError;
|
||||
|
||||
// 초기화 함수
|
||||
void initialize(String token) {
|
||||
@@ -61,6 +65,7 @@ class ScoreService with ChangeNotifier {
|
||||
print('fetchClubScores 호출됨: token=$_token');
|
||||
|
||||
_isLoading = true;
|
||||
_lastError = null;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
@@ -92,9 +97,11 @@ class ScoreService with ChangeNotifier {
|
||||
}
|
||||
|
||||
_isLoading = false;
|
||||
_lastLoadedAt = DateTime.now();
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
_isLoading = false;
|
||||
_lastError = e.toString();
|
||||
notifyListeners();
|
||||
throw Exception('점수 목록을 불러오는데 실패했습니다: $e');
|
||||
}
|
||||
@@ -107,6 +114,7 @@ class ScoreService with ChangeNotifier {
|
||||
}
|
||||
|
||||
_isLoading = true;
|
||||
_lastError = null;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
@@ -127,6 +135,7 @@ class ScoreService with ChangeNotifier {
|
||||
return memberScores;
|
||||
} catch (e) {
|
||||
_isLoading = false;
|
||||
_lastError = e.toString();
|
||||
notifyListeners();
|
||||
throw Exception('회원 점수를 불러오는데 실패했습니다: $e');
|
||||
}
|
||||
@@ -139,6 +148,7 @@ class ScoreService with ChangeNotifier {
|
||||
}
|
||||
|
||||
_isLoading = true;
|
||||
_lastError = null;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
@@ -159,6 +169,7 @@ class ScoreService with ChangeNotifier {
|
||||
return eventScores;
|
||||
} catch (e) {
|
||||
_isLoading = false;
|
||||
_lastError = e.toString();
|
||||
notifyListeners();
|
||||
throw Exception('이벤트 점수를 불러오는데 실패했습니다: $e');
|
||||
}
|
||||
@@ -171,6 +182,7 @@ class ScoreService with ChangeNotifier {
|
||||
}
|
||||
|
||||
_isLoading = true;
|
||||
_lastError = null;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
@@ -186,6 +198,7 @@ class ScoreService with ChangeNotifier {
|
||||
return newScore;
|
||||
} catch (e) {
|
||||
_isLoading = false;
|
||||
_lastError = e.toString();
|
||||
notifyListeners();
|
||||
throw Exception('점수 추가에 실패했습니다: $e');
|
||||
}
|
||||
@@ -223,6 +236,7 @@ class ScoreService with ChangeNotifier {
|
||||
return updatedScore;
|
||||
} catch (e) {
|
||||
_isLoading = false;
|
||||
_lastError = e.toString();
|
||||
notifyListeners();
|
||||
throw Exception('점수 수정에 실패했습니다: $e');
|
||||
}
|
||||
@@ -249,6 +263,7 @@ class ScoreService with ChangeNotifier {
|
||||
return true;
|
||||
} catch (e) {
|
||||
_isLoading = false;
|
||||
_lastError = e.toString();
|
||||
notifyListeners();
|
||||
throw Exception('점수 삭제에 실패했습니다: $e');
|
||||
}
|
||||
@@ -257,17 +272,21 @@ class ScoreService with ChangeNotifier {
|
||||
// 회원 통계 가져오기
|
||||
Future<ScoreStatistics> fetchMemberStatistics(String memberId) async {
|
||||
if (_token == null || _clubId == null) {
|
||||
throw Exception('인증 또는 클럽 정보가 필요합니다');
|
||||
throw Exception('인증 정보가 필요합니다');
|
||||
}
|
||||
|
||||
_lastError = null;
|
||||
|
||||
try {
|
||||
final data = await _apiService.post(
|
||||
'${ApiConfig.clubs}/members/stats',
|
||||
data: {'memberId': memberId},
|
||||
);
|
||||
|
||||
_lastLoadedAt = DateTime.now();
|
||||
return ScoreStatistics.fromJson(data['statistics']);
|
||||
} catch (e) {
|
||||
_lastError = e.toString();
|
||||
throw Exception('회원 통계를 불러오는데 실패했습니다: $e');
|
||||
}
|
||||
}
|
||||
@@ -309,8 +328,11 @@ class ScoreService with ChangeNotifier {
|
||||
});
|
||||
}
|
||||
|
||||
_lastLoadedAt = DateTime.now();
|
||||
_lastError = null;
|
||||
return statistics;
|
||||
} catch (e) {
|
||||
_lastError = e.toString();
|
||||
throw Exception('클럽 통계를 불러오는데 실패했습니다: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 배지(Chip) 스타일 매핑과 헬퍼
|
||||
class BadgeStyles {
|
||||
// 색상 팔레트
|
||||
static const Color primary = Color(0xFF1565C0); // Blue 700
|
||||
static const Color success = Color(0xFF2E7D32); // Green 800
|
||||
static const Color warning = Color(0xFFEF6C00); // Orange 800
|
||||
static const Color danger = Color(0xFFC62828); // Red 800
|
||||
|
||||
// 배경 톤 (100 계열 느낌)
|
||||
static final Color primaryBg = Colors.blue.shade100;
|
||||
static final Color successBg = Colors.green.shade100;
|
||||
static final Color dangerBg = Colors.red.shade100;
|
||||
static final Color neutralBg = Colors.grey.shade200;
|
||||
|
||||
// 회원 유형 컬러/아이콘 매핑
|
||||
static Chip memberType(String type) {
|
||||
late final Color bg;
|
||||
late final IconData icon;
|
||||
switch (type) {
|
||||
case '정회원':
|
||||
bg = primaryBg;
|
||||
icon = Icons.person;
|
||||
break;
|
||||
case '준회원':
|
||||
bg = Colors.indigo.shade100;
|
||||
icon = Icons.person_outline;
|
||||
break;
|
||||
case '게스트':
|
||||
bg = neutralBg;
|
||||
icon = Icons.person_add_alt;
|
||||
break;
|
||||
default:
|
||||
bg = neutralBg;
|
||||
icon = Icons.person_outline;
|
||||
}
|
||||
return _chip(type, bg, icon);
|
||||
}
|
||||
|
||||
// 참가 상태 컬러/아이콘 매핑
|
||||
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;
|
||||
}
|
||||
return _chip(status, bg, icon);
|
||||
}
|
||||
|
||||
// 회원 상태 컬러/아이콘 매핑 (라벨 기준: 활성/비활성/정지/삭제됨)
|
||||
static Chip memberStatus(String label) {
|
||||
late final Color bg;
|
||||
late final IconData icon;
|
||||
switch (label) {
|
||||
case '활성':
|
||||
bg = successBg;
|
||||
icon = Icons.check_circle;
|
||||
break;
|
||||
case '비활성':
|
||||
bg = neutralBg;
|
||||
icon = Icons.pause_circle_filled;
|
||||
break;
|
||||
case '정지':
|
||||
bg = dangerBg;
|
||||
icon = Icons.block;
|
||||
break;
|
||||
case '삭제됨':
|
||||
bg = Colors.grey.shade300;
|
||||
icon = Icons.delete_forever;
|
||||
break;
|
||||
default:
|
||||
bg = neutralBg;
|
||||
icon = Icons.person;
|
||||
}
|
||||
return _chip(label, bg, icon);
|
||||
}
|
||||
|
||||
// 결제 상태 컬러/아이콘 매핑
|
||||
static Chip payment(bool isPaid) {
|
||||
return _chip(
|
||||
isPaid ? '결제완료' : '미결제',
|
||||
isPaid ? successBg : dangerBg,
|
||||
isPaid ? Icons.check_circle : Icons.cancel,
|
||||
);
|
||||
}
|
||||
|
||||
// 이벤트 상태 컬러/아이콘 매핑
|
||||
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;
|
||||
}
|
||||
return _chip(status, bg, icon);
|
||||
}
|
||||
|
||||
static Chip _chip(String text, Color bg, IconData icon) {
|
||||
// 배경 대비에 따라 텍스트/아이콘 색상을 자동 설정 (흰/검정 중 더 높은 대비 선택)
|
||||
double lumBg = bg.computeLuminance();
|
||||
// 대비비 계산 함수 (WCAG 근사)
|
||||
double contrast(double lum1, double lum2) {
|
||||
final double l1 = lum1 > lum2 ? lum1 : lum2;
|
||||
final double l2 = lum1 > lum2 ? lum2 : lum1;
|
||||
return (l1 + 0.05) / (l2 + 0.05);
|
||||
}
|
||||
final double contrastWithWhite = contrast(lumBg, Colors.white.computeLuminance());
|
||||
final double contrastWithBlack = contrast(lumBg, Colors.black.computeLuminance());
|
||||
final Color fg = contrastWithWhite >= contrastWithBlack ? Colors.white : Colors.black;
|
||||
return Chip(
|
||||
avatar: Icon(icon, size: 16, color: fg),
|
||||
label: Text(text, style: TextStyle(color: fg)),
|
||||
backgroundColor: bg,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 0),
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import 'dart:convert';
|
||||
|
||||
/// CSV 파일에서 이벤트 데이터를 파싱하는 유틸리티 클래스
|
||||
/// 반환 형식은 EventExcelParser와 동일하게 맞춥니다.
|
||||
class EventCsvParser {
|
||||
/// CSV 바이트에서 이벤트 데이터를 추출합니다.
|
||||
/// 헤더는 첫 줄로 가정하며, 필수 컬럼은 title, startDate 입니다.
|
||||
/// 반환값 키:
|
||||
/// - processedRows, validEvents, invalidRows, invalidRowIndices, error
|
||||
static Map<String, dynamic> parseCsvBytes(List<int> bytes) {
|
||||
final content = utf8.decode(bytes, allowMalformed: true);
|
||||
final lines = _splitLines(content)
|
||||
.where((e) => e.trim().isNotEmpty)
|
||||
.toList();
|
||||
|
||||
if (lines.isEmpty) {
|
||||
return {
|
||||
'processedRows': 0,
|
||||
'validEvents': <Map<String, dynamic>>[],
|
||||
'invalidRows': 0,
|
||||
'invalidRowIndices': <int>[],
|
||||
'error': 'CSV 파일에 데이터가 없습니다.'
|
||||
};
|
||||
}
|
||||
|
||||
final headers = _parseCsvLine(lines.first)
|
||||
.map((e) => e.trim().toLowerCase())
|
||||
.toList();
|
||||
|
||||
if (!headers.contains('title') || !headers.contains('startdate')) {
|
||||
return {
|
||||
'processedRows': 0,
|
||||
'validEvents': <Map<String, dynamic>>[],
|
||||
'invalidRows': 0,
|
||||
'invalidRowIndices': <int>[],
|
||||
'error': '필수 필드(title, startDate)가 없습니다.'
|
||||
};
|
||||
}
|
||||
|
||||
final events = <Map<String, dynamic>>[];
|
||||
int processedRows = 0;
|
||||
int invalidRows = 0;
|
||||
final invalidRowIndices = <int>[];
|
||||
final invalidRowReasons = <int, String>{};
|
||||
|
||||
for (var i = 1; i < lines.length; i++) {
|
||||
final row = _parseCsvLine(lines[i]);
|
||||
if (row.isEmpty || row.every((c) => c.trim().isEmpty)) {
|
||||
// 빈 행은 스킵
|
||||
continue;
|
||||
}
|
||||
processedRows++;
|
||||
|
||||
final event = <String, dynamic>{};
|
||||
for (var j = 0; j < headers.length && j < row.length; j++) {
|
||||
final header = headers[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') {
|
||||
// 날짜 문자열은 그대로 전달하고, 화면/서버 측에서 ISO 변환/검증 처리
|
||||
// 비어있으면 startDate는 필수, endDate는 null
|
||||
if (normalizedHeader == 'startDate') {
|
||||
event['startDate'] = value.isEmpty ? DateTime.now().toIso8601String() : value;
|
||||
} else {
|
||||
event['endDate'] = value.isEmpty ? null : value;
|
||||
}
|
||||
} else {
|
||||
event[normalizedHeader] = value.isEmpty ? null : value;
|
||||
}
|
||||
}
|
||||
|
||||
final titleVal = (event['title'] ?? '').toString().trim();
|
||||
final startVal = (event['startDate'] ?? '').toString().trim();
|
||||
if (titleVal.isNotEmpty && startVal.isNotEmpty) {
|
||||
events.add(event);
|
||||
} else {
|
||||
invalidRows++;
|
||||
final rowNo = i + 1;
|
||||
invalidRowIndices.add(rowNo); // 헤더가 1행이므로 +1
|
||||
final reasons = <String>[];
|
||||
if (titleVal.isEmpty) reasons.add('title 누락');
|
||||
if (startVal.isEmpty) reasons.add('startDate 누락');
|
||||
invalidRowReasons[rowNo] = reasons.join(', ');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
'processedRows': processedRows,
|
||||
'validEvents': events,
|
||||
'invalidRows': invalidRows,
|
||||
'invalidRowIndices': invalidRowIndices,
|
||||
'invalidRowReasons': invalidRowReasons,
|
||||
'error': events.isEmpty ? '유효한 이벤트 데이터가 없습니다.' : null
|
||||
};
|
||||
}
|
||||
|
||||
// 간단한 CSV 라인 파서: 따옴표 포함 필드와 이스케이프된 따옴표("") 처리
|
||||
static List<String> _parseCsvLine(String line) {
|
||||
final result = <String>[];
|
||||
final buf = StringBuffer();
|
||||
bool inQuotes = false;
|
||||
|
||||
for (int i = 0; i < line.length; i++) {
|
||||
final ch = line[i];
|
||||
if (inQuotes) {
|
||||
if (ch == '"') {
|
||||
// 이스케이프된 따옴표
|
||||
if (i + 1 < line.length && line[i + 1] == '"') {
|
||||
buf.write('"');
|
||||
i++;
|
||||
} else {
|
||||
inQuotes = false;
|
||||
}
|
||||
} else {
|
||||
buf.write(ch);
|
||||
}
|
||||
} else {
|
||||
if (ch == ',') {
|
||||
result.add(buf.toString());
|
||||
buf.clear();
|
||||
} else if (ch == '"') {
|
||||
inQuotes = true;
|
||||
} else {
|
||||
buf.write(ch);
|
||||
}
|
||||
}
|
||||
}
|
||||
result.add(buf.toString());
|
||||
return result;
|
||||
}
|
||||
|
||||
static List<String> _splitLines(String content) {
|
||||
return content.replaceAll('\r\n', '\n').replaceAll('\r', '\n').split('\n');
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,8 @@ class EventExcelParser {
|
||||
final events = <Map<String, dynamic>>[];
|
||||
int invalidRows = 0;
|
||||
int processedRows = 0;
|
||||
final List<int> invalidRowIndices = [];
|
||||
final Map<int, String> invalidRowReasons = {};
|
||||
|
||||
// 첫 번째 시트 사용
|
||||
final sheet = excel.tables.keys.first;
|
||||
@@ -56,48 +58,52 @@ class EventExcelParser {
|
||||
// 각 셀 처리
|
||||
for (var j = 0; j < headers.length && j < row.length; j++) {
|
||||
final header = headers[j];
|
||||
final value = row[j]?.value;
|
||||
|
||||
// 날짜 필드 특별 처리
|
||||
if (header == 'startdate' || header == 'enddate') {
|
||||
DateTime? date = _parseDate(value);
|
||||
|
||||
// 날짜 값 설정
|
||||
if (date != null) {
|
||||
// 헤더 이름 정규화 (startdate -> startDate)
|
||||
final normalizedHeader = header == 'startdate' ? 'startDate' : 'endDate';
|
||||
event[normalizedHeader] = date.toIso8601String();
|
||||
} else {
|
||||
// 날짜 파싱 실패 시 startDate는 필수이므로 현재 날짜 사용
|
||||
if (header == 'startdate') {
|
||||
event['startDate'] = DateTime.now().toIso8601String();
|
||||
} else {
|
||||
// endDate는 선택적이므로 null 설정
|
||||
event['endDate'] = null;
|
||||
}
|
||||
}
|
||||
DateTime? date = _parseDate(row[j]?.value);
|
||||
|
||||
// 날짜 값 설정: 파싱 성공 시만 값 지정, 실패 시 null 유지
|
||||
final normalizedHeader = header == 'startdate' ? 'startDate' : 'endDate';
|
||||
event[normalizedHeader] = (date != null) ? date.toIso8601String() : null;
|
||||
} else {
|
||||
// 문자열 필드의 경우 빈 문자열은 null로 처리
|
||||
final stringValue = value?.toString().trim() ?? '';
|
||||
|
||||
// 문자열/일반 필드: excel v4의 TextCellValue 등 실제 값 추출
|
||||
String raw = '';
|
||||
final cellVal = row[j]?.value;
|
||||
if (cellVal is TextCellValue) {
|
||||
raw = cellVal.value.toString();
|
||||
} else if (cellVal != null) {
|
||||
raw = cellVal.toString();
|
||||
}
|
||||
final stringValue = raw.trim();
|
||||
|
||||
// 헤더 이름 정규화 (특별한 경우)
|
||||
String normalizedHeader = header;
|
||||
if (header == 'title' || header == 'description' || header == 'location' ||
|
||||
header == 'status' || header == 'type' || header == 'maxparticipants') {
|
||||
// 첫 글자를 소문자로 유지하고 나머지는 원래 형식 유지
|
||||
normalizedHeader = header == 'maxparticipants' ? 'maxParticipants' : header;
|
||||
}
|
||||
|
||||
|
||||
// 빈 문자열은 null로 처리
|
||||
event[normalizedHeader] = stringValue.isEmpty ? null : stringValue;
|
||||
}
|
||||
}
|
||||
|
||||
// 필수 필드가 있는 경우만 추가
|
||||
if (event.containsKey('title') && event.containsKey('startDate')) {
|
||||
final hasTitle = event.containsKey('title') && (event['title']?.toString().trim().isNotEmpty ?? false);
|
||||
final hasStart = event.containsKey('startDate') && (event['startDate']?.toString().trim().isNotEmpty ?? false);
|
||||
if (hasTitle && hasStart) {
|
||||
events.add(event);
|
||||
} else {
|
||||
invalidRows++;
|
||||
// 데이터 행 기준(헤더 제외) 1-based가 아닌, 테스트 기대값에 맞춰 i 사용
|
||||
// 즉, 헤더가 0, 첫 데이터 행이 i=1이므로 무효 행 번호로 i를 기록
|
||||
final rowNo = i;
|
||||
invalidRowIndices.add(rowNo);
|
||||
final reasons = <String>[];
|
||||
if (!hasTitle) reasons.add('title 누락');
|
||||
if (!hasStart) reasons.add('startDate 누락');
|
||||
invalidRowReasons[rowNo] = reasons.join(', ');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,6 +111,8 @@ class EventExcelParser {
|
||||
'processedRows': processedRows,
|
||||
'validEvents': events,
|
||||
'invalidRows': invalidRows,
|
||||
'invalidRowIndices': invalidRowIndices,
|
||||
'invalidRowReasons': invalidRowReasons,
|
||||
'error': events.isEmpty ? '유효한 이벤트 데이터가 없습니다.' : null
|
||||
};
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ class TestDiagnostics {
|
||||
// 프로세스 정보 (플랫폼별 처리)
|
||||
if (Platform.isLinux || Platform.isMacOS) {
|
||||
try {
|
||||
final result = await Process.run('ps', ['-o', 'pid,ppid,rss,vsz,pcpu,pmem,command', '-p', '${pid}']);
|
||||
final result = await Process.run('ps', ['-o', 'pid,ppid,rss,vsz,pcpu,pmem,command', '-p', '${currentPid}']);
|
||||
_log('프로세스 정보:\n${result.stdout}');
|
||||
} catch (e) {
|
||||
_log('프로세스 정보 수집 실패: $e');
|
||||
@@ -197,5 +197,5 @@ class TestDiagnostics {
|
||||
}
|
||||
|
||||
/// 현재 프로세스 ID
|
||||
static int get pid => pid;
|
||||
static int get currentPid => pid;
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ class TestUtils {
|
||||
|
||||
return MediaQuery(
|
||||
data: const MediaQueryData(
|
||||
textScaleFactor: 1.0,
|
||||
textScaler: TextScaler.linear(1.0),
|
||||
platformBrightness: Brightness.light,
|
||||
padding: EdgeInsets.zero,
|
||||
viewInsets: EdgeInsets.zero,
|
||||
|
||||
@@ -177,100 +177,6 @@ class TieBreakerUtil {
|
||||
});
|
||||
}
|
||||
|
||||
/// 동점자 그룹 찾기
|
||||
static Map<int, List<Score>> _findTiedGroups(List<Score> sortedScores, bool includeHandicap) {
|
||||
Map<int, List<Score>> tiedGroups = {};
|
||||
|
||||
for (int i = 0; i < sortedScores.length; i++) {
|
||||
int currentScore = includeHandicap
|
||||
? (sortedScores[i].totalScore + (sortedScores[i].handicap ?? 0))
|
||||
: sortedScores[i].totalScore;
|
||||
|
||||
List<Score> sameScores = [sortedScores[i]];
|
||||
|
||||
// 같은 점수를 가진 항목 찾기
|
||||
for (int j = i + 1; j < sortedScores.length; j++) {
|
||||
int nextScore = includeHandicap
|
||||
? (sortedScores[j].totalScore + (sortedScores[j].handicap ?? 0))
|
||||
: sortedScores[j].totalScore;
|
||||
|
||||
if (currentScore == nextScore) {
|
||||
sameScores.add(sortedScores[j]);
|
||||
i = j; // 이미 처리한 항목 건너뛰기
|
||||
} else {
|
||||
break; // 다른 점수 발견 시 중단
|
||||
}
|
||||
}
|
||||
|
||||
// 동점자가 2명 이상인 경우만 그룹에 추가
|
||||
if (sameScores.length > 1) {
|
||||
tiedGroups[currentScore] = sameScores;
|
||||
}
|
||||
}
|
||||
|
||||
return tiedGroups;
|
||||
}
|
||||
|
||||
/// 동점자 처리 옵션 적용
|
||||
static List<Score> _applyTieBreakerOption(
|
||||
List<Score> tiedScores,
|
||||
TieBreakerOption option,
|
||||
List<Member>? members,
|
||||
bool includeHandicap
|
||||
) {
|
||||
switch (option) {
|
||||
case TieBreakerOption.lowerHandicap:
|
||||
return _sortByLowerHandicap(tiedScores);
|
||||
|
||||
case TieBreakerOption.lowerScoreGap:
|
||||
return _sortByLowerScoreGap(tiedScores);
|
||||
|
||||
case TieBreakerOption.olderAge:
|
||||
return _sortByOlderAge(tiedScores, members);
|
||||
|
||||
case TieBreakerOption.none:
|
||||
return tiedScores; // 변경 없음
|
||||
}
|
||||
}
|
||||
|
||||
/// 핸디캡이 적은 순으로 정렬
|
||||
static List<Score> _sortByLowerHandicap(List<Score> tiedScores) {
|
||||
return List.from(tiedScores)..sort((a, b) {
|
||||
int handicapA = a.handicap ?? 0;
|
||||
int handicapB = b.handicap ?? 0;
|
||||
return handicapA.compareTo(handicapB); // 오름차순 (적은 것이 우선)
|
||||
});
|
||||
}
|
||||
|
||||
/// 점수 격차가 적은 순으로 정렬
|
||||
static List<Score> _sortByLowerScoreGap(List<Score> tiedScores) {
|
||||
return List.from(tiedScores)..sort((a, b) {
|
||||
// 프레임 점수 중 최고점과 최저점의 차이 계산
|
||||
int gapA = _calculateScoreGap(a.frames);
|
||||
int gapB = _calculateScoreGap(b.frames);
|
||||
return gapA.compareTo(gapB); // 오름차순 (격차가 적은 것이 우선)
|
||||
});
|
||||
}
|
||||
|
||||
/// 연장자 우선으로 정렬
|
||||
static List<Score> _sortByOlderAge(List<Score> tiedScores, List<Member>? members) {
|
||||
if (members == null || members.isEmpty) return tiedScores;
|
||||
|
||||
return List.from(tiedScores)..sort((a, b) {
|
||||
// 회원 정보에서 생년월일 찾기
|
||||
DateTime? birthDateA = _findMemberBirthDate(a.memberId, members);
|
||||
DateTime? birthDateB = _findMemberBirthDate(b.memberId, members);
|
||||
|
||||
// 생년월일이 없으면 정렬 불가
|
||||
if (birthDateA == null || birthDateB == null) return 0;
|
||||
|
||||
// 생년월일 비교 (오래된 날짜 = 연장자가 우선)
|
||||
// 연장자가 우선이므로 더 오래된 날짜(더 작은 값)가 앞에 와야 함
|
||||
// 연장자 우선이므로 더 오래된 날짜(A)가 더 최근 날짜(B)보다 앞에 와야 함
|
||||
return birthDateA.compareTo(birthDateB); // 오름차순 정렬로 연장자 우선
|
||||
});
|
||||
}
|
||||
|
||||
/// 회원 ID로 생년월일 찾기
|
||||
static DateTime? _findMemberBirthDate(String? memberId, List<Member> members) {
|
||||
if (memberId == null) return null;
|
||||
@@ -293,25 +199,6 @@ class TieBreakerUtil {
|
||||
return max - min;
|
||||
}
|
||||
|
||||
/// 모든 점수가 서로 다른지 확인
|
||||
static bool _allScoresHaveDifferentValues(List<Score> scores, bool includeHandicap) {
|
||||
Set<int> uniqueScores = {};
|
||||
|
||||
for (var score in scores) {
|
||||
int totalScore = includeHandicap
|
||||
? (score.totalScore + (score.handicap ?? 0))
|
||||
: score.totalScore;
|
||||
|
||||
if (uniqueScores.contains(totalScore)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
uniqueScores.add(totalScore);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 정렬된 동점자 그룹으로 원래 목록 업데이트
|
||||
static Map<String, int> calculateRanks(
|
||||
List<Score> scores,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class DialogActions {
|
||||
static List<Widget> confirm({
|
||||
required VoidCallback onConfirm,
|
||||
required VoidCallback onCancel,
|
||||
String confirmText = '확인',
|
||||
String cancelText = '취소',
|
||||
bool destructive = false,
|
||||
}) {
|
||||
return [
|
||||
TextButton(
|
||||
onPressed: onCancel,
|
||||
child: Text(cancelText),
|
||||
),
|
||||
ElevatedButton(
|
||||
style: destructive
|
||||
? ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.redAccent,
|
||||
foregroundColor: Colors.white,
|
||||
)
|
||||
: null,
|
||||
onPressed: onConfirm,
|
||||
child: Text(confirmText),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../widgets/dialog_actions.dart';
|
||||
|
||||
class ImportResultDialog extends StatelessWidget {
|
||||
final String fileName;
|
||||
final int processedRows;
|
||||
final int createdCount;
|
||||
final int invalidRows;
|
||||
final List<String> invalidRowIndices;
|
||||
final Map<String, String> invalidRowReasons;
|
||||
final VoidCallback onClose;
|
||||
|
||||
const ImportResultDialog({
|
||||
super.key,
|
||||
required this.fileName,
|
||||
required this.processedRows,
|
||||
required this.createdCount,
|
||||
required this.invalidRows,
|
||||
required this.invalidRowIndices,
|
||||
required this.invalidRowReasons,
|
||||
required this.onClose,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('파일 처리 결과'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('파일명: $fileName'),
|
||||
const SizedBox(height: 8),
|
||||
Text('처리된 행: $processedRows개'),
|
||||
Text('생성된 이벤트: $createdCount개'),
|
||||
if (invalidRows > 0) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text('유효하지 않은 행: $invalidRows개', style: const TextStyle(color: Colors.red)),
|
||||
if (invalidRowIndices.isNotEmpty)
|
||||
Text(
|
||||
'실패 행(최대 10개 미리보기): ${invalidRowIndices.take(10).join(', ')}',
|
||||
style: const TextStyle(fontSize: 12, color: Colors.redAccent),
|
||||
),
|
||||
if (invalidRowReasons.isNotEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
const Text('실패 사유(일부):', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600)),
|
||||
...invalidRowReasons.entries.take(5).map(
|
||||
(e) => Text('행 ${e.key}: ${e.value}', style: const TextStyle(fontSize: 12, color: Colors.redAccent)),
|
||||
),
|
||||
if (invalidRowReasons.length > 5)
|
||||
Text('... 외 ${invalidRowReasons.length - 5}건', style: const TextStyle(fontSize: 12, color: Colors.redAccent)),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
actions: DialogActions.confirm(
|
||||
onCancel: onClose,
|
||||
onConfirm: onClose,
|
||||
confirmText: '닫기',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/member_model.dart';
|
||||
import '../utils/enum_mappings.dart';
|
||||
|
||||
class OwnerDropdown extends StatelessWidget {
|
||||
const OwnerDropdown({
|
||||
super.key,
|
||||
required this.members,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
this.enabled = true,
|
||||
this.onItemsBuilt,
|
||||
});
|
||||
|
||||
final List<Member> members;
|
||||
final String? value;
|
||||
final ValueChanged<String?> onChanged;
|
||||
final bool enabled;
|
||||
|
||||
/// 테스트 전용: 빌드된 항목 수를 알리기 위한 콜백(프로덕션에서는 사용하지 않음)
|
||||
final ValueChanged<int>? onItemsBuilt;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final items = members.map((member) {
|
||||
return DropdownMenuItem<String>(
|
||||
value: member.userId,
|
||||
child: Text(
|
||||
'${member.name} (${getMemberTypeLabel(member.memberType)})',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
);
|
||||
}).toList();
|
||||
|
||||
// 테스트에서만 사용
|
||||
if (onItemsBuilt != null) {
|
||||
// scheduleMicrotask로 프레임 안전하게 호출
|
||||
Future.microtask(() => onItemsBuilt!(items.length));
|
||||
}
|
||||
|
||||
return DropdownButtonFormField<String>(
|
||||
decoration: const InputDecoration(
|
||||
labelText: '모임장 설정',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
isExpanded: true,
|
||||
value: value,
|
||||
items: items,
|
||||
onChanged: enabled ? onChanged : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// PrimeVue 스타일의 뱃지 위젯
|
||||
///
|
||||
///
|
||||
/// [label] - 뱃지에 표시할 텍스트
|
||||
/// [color] - 뱃지의 배경색
|
||||
/// [icon] - 뱃지 앞에 표시할 아이콘 (선택사항)
|
||||
@@ -19,7 +19,7 @@ class PrimeBadge extends StatelessWidget {
|
||||
final bool rounded;
|
||||
|
||||
const PrimeBadge({
|
||||
Key? key,
|
||||
super.key,
|
||||
required this.label,
|
||||
this.color,
|
||||
this.icon,
|
||||
@@ -27,22 +27,22 @@ class PrimeBadge extends StatelessWidget {
|
||||
this.size = 'normal',
|
||||
this.outlined = false,
|
||||
this.rounded = true,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 색상 결정 (severity 또는 직접 지정된 색상)
|
||||
Color backgroundColor = _getBackgroundColor();
|
||||
Color textColor = _getTextColor(backgroundColor);
|
||||
|
||||
|
||||
// 크기에 따른 패딩 및 폰트 크기 설정
|
||||
EdgeInsets padding = _getPadding();
|
||||
double fontSize = _getFontSize();
|
||||
double iconSize = _getIconSize();
|
||||
|
||||
|
||||
// 테두리 반경 설정
|
||||
double borderRadius = rounded ? 16.0 : 4.0;
|
||||
|
||||
|
||||
return Container(
|
||||
padding: padding,
|
||||
decoration: BoxDecoration(
|
||||
@@ -78,7 +78,7 @@ class PrimeBadge extends StatelessWidget {
|
||||
if (color != null) {
|
||||
return color!;
|
||||
}
|
||||
|
||||
|
||||
switch (severity) {
|
||||
case 'info':
|
||||
return Colors.blue;
|
||||
|
||||
@@ -16,8 +16,8 @@ class SubscriptionAlertWidget extends StatelessWidget {
|
||||
listen: true,
|
||||
);
|
||||
|
||||
// 구독 서비스 가져오기
|
||||
final subscriptionService = Provider.of<SubscriptionService>(
|
||||
// 구독 서비스 가져오기 (현재 위젯에서는 직접 사용하지 않음)
|
||||
Provider.of<SubscriptionService>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user