이벤트 가져오기 위젯 테스트 추가 및 테스트 훅 도입

This commit is contained in:
2025-09-12 06:33:44 +09:00
parent 107abc963f
commit a5f2544d11
121 changed files with 39277 additions and 3911 deletions
+5 -1
View File
@@ -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
View File
@@ -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('알림 권한이 거부되었습니다. 설정에서 권한을 허용해주세요')),
);
}
+6 -4
View File
@@ -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),
+29 -10
View File
@@ -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
+125 -65
View File
@@ -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),
+392 -155
View File
@@ -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),
+146 -161
View File
@@ -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(
+134 -123
View File
@@ -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 ? '자동 갱신이 활성화되었습니다' : '자동 갱신이 비활성화되었습니다'),
),
+7 -2
View File
@@ -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 -2
View File
@@ -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;
+36 -24
View File
@@ -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();
}
}
+2 -2
View File
@@ -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();
+74 -14
View File
@@ -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 {
+59 -50
View File
@@ -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: {...}}
+23 -1
View File
@@ -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');
}
}
+154
View File
@@ -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,
);
}
}
+141
View File
@@ -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');
}
}
+31 -23
View File
@@ -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
};
}
+2 -2
View File
@@ -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;
}
+1 -1
View File
@@ -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,
-113
View File
@@ -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,
+28
View File
@@ -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: '닫기',
),
);
}
}
+54
View File
@@ -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,
);
}
}
+7 -7
View File
@@ -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,
);