이벤트 가져오기 위젯 테스트 추가 및 테스트 훅 도입
This commit is contained in:
@@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
|
||||
import '../../services/admin_service.dart';
|
||||
import '../../services/subscription_service.dart';
|
||||
import '../../models/subscription_model.dart';
|
||||
import '../../widgets/dialog_actions.dart';
|
||||
|
||||
/// 관리자 대시보드 화면
|
||||
class AdminDashboardScreen extends StatefulWidget {
|
||||
@@ -32,18 +33,21 @@ class _AdminDashboardScreenState extends State<AdminDashboardScreen> {
|
||||
try {
|
||||
final adminService = Provider.of<AdminService>(context, listen: false);
|
||||
final analyticsData = await adminService.fetchClubAnalytics();
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_analyticsData = analyticsData;
|
||||
});
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('데이터 로드 중 오류가 발생했습니다: $e')),
|
||||
);
|
||||
} finally {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -374,19 +378,20 @@ class _AdminDashboardScreenState extends State<AdminDashboardScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
],
|
||||
actions: DialogActions.confirm(
|
||||
onCancel: () => Navigator.of(context).pop(),
|
||||
onConfirm: () => Navigator.of(context).pop(),
|
||||
confirmText: '닫기',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 데이터 내보내기 처리
|
||||
Future<void> _exportData(BuildContext context, String dataType) async {
|
||||
Navigator.of(context).pop(); // 다이얼로그 닫기
|
||||
final navigator = Navigator.of(context);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
navigator.pop(); // 다이얼로그 닫기
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
@@ -397,22 +402,24 @@ class _AdminDashboardScreenState extends State<AdminDashboardScreen> {
|
||||
final downloadUrl = await adminService.exportClubData(dataType);
|
||||
|
||||
if (downloadUrl != null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('데이터 내보내기 성공: $downloadUrl')),
|
||||
);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(content: Text('데이터 내보내기에 실패했습니다.')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('데이터 내보내기 중 오류가 발생했습니다: $e')),
|
||||
);
|
||||
} finally {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _emailController = TextEditingController();
|
||||
bool _isSubmitting = false;
|
||||
bool _emailSent = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
@@ -35,7 +34,6 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
|
||||
|
||||
setState(() {
|
||||
_isSubmitting = false;
|
||||
_emailSent = success;
|
||||
});
|
||||
|
||||
if (success && mounted) {
|
||||
|
||||
@@ -6,14 +6,20 @@ import '../../services/auth_service.dart';
|
||||
import '../../services/club_service.dart';
|
||||
import '../../services/member_service.dart';
|
||||
import '../../utils/enum_mappings.dart';
|
||||
import '../../widgets/owner_dropdown.dart';
|
||||
|
||||
class ClubSettingsScreen extends StatefulWidget {
|
||||
static const routeName = '/club-settings';
|
||||
|
||||
const ClubSettingsScreen({super.key});
|
||||
const ClubSettingsScreen({super.key, this.onOwnerItemsBuilt, this.initialMembers});
|
||||
|
||||
// 테스트 전용: 모임장 드롭다운 항목 개수 리포트 콜백
|
||||
final ValueChanged<int>? onOwnerItemsBuilt;
|
||||
// 테스트 전용: 초기 멤버 목록을 직접 주입하여 로딩을 우회
|
||||
final List<Member>? initialMembers;
|
||||
|
||||
@override
|
||||
_ClubSettingsScreenState createState() => _ClubSettingsScreenState();
|
||||
State<ClubSettingsScreen> createState() => _ClubSettingsScreenState();
|
||||
}
|
||||
|
||||
class _ClubSettingsScreenState extends State<ClubSettingsScreen> {
|
||||
@@ -32,7 +38,14 @@ class _ClubSettingsScreenState extends State<ClubSettingsScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadData();
|
||||
if (widget.initialMembers != null) {
|
||||
// 테스트 경로: 초기 데이터 주입
|
||||
_members = List<Member>.from(widget.initialMembers!);
|
||||
_isLoading = false;
|
||||
_hasEditPermission = true; // 테스트에서는 편집 가능 상태로 둠
|
||||
} else {
|
||||
_loadData();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -49,6 +62,11 @@ class _ClubSettingsScreenState extends State<ClubSettingsScreen> {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
// await 이전에 messenger 캡처 (catch에서 사용)
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
// await 이전에 authService 캡처 (이후 권한 계산에 사용)
|
||||
final authService = Provider.of<AuthService>(context, listen: false);
|
||||
|
||||
try {
|
||||
// 클럽 정보 로드
|
||||
final clubService = Provider.of<ClubService>(context, listen: false);
|
||||
@@ -72,7 +90,6 @@ class _ClubSettingsScreenState extends State<ClubSettingsScreen> {
|
||||
});
|
||||
|
||||
// 권한 확인
|
||||
final authService = Provider.of<AuthService>(context, listen: false);
|
||||
final currentUserId = authService.currentUser?.id;
|
||||
|
||||
// 현재 사용자가 클럽 소유자이거나 관리자인지 확인
|
||||
@@ -98,9 +115,9 @@ class _ClubSettingsScreenState extends State<ClubSettingsScreen> {
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('데이터 로드 중 오류가 발생했습니다: $error')));
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('데이터 로드 중 오류가 발생했습니다: $error')),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
@@ -119,6 +136,9 @@ class _ClubSettingsScreenState extends State<ClubSettingsScreen> {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
// await 이전에 messenger 캡처
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
|
||||
try {
|
||||
final clubService = Provider.of<ClubService>(context, listen: false);
|
||||
final club = clubService.currentClub;
|
||||
@@ -185,18 +205,14 @@ class _ClubSettingsScreenState extends State<ClubSettingsScreen> {
|
||||
|
||||
await clubService.updateClub(club.id, updates);
|
||||
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('클럽 정보가 성공적으로 저장되었습니다')));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('클럽 정보 저장 중 오류가 발생했습니다: $error')),
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(content: Text('클럽 정보가 성공적으로 저장되었습니다')),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('클럽 정보 저장 중 오류가 발생했습니다: $error')),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
@@ -294,16 +310,22 @@ class _ClubSettingsScreenState extends State<ClubSettingsScreen> {
|
||||
// 평균 산정 기준
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<String>(
|
||||
key: const Key('club_settings_avg_period_dropdown'),
|
||||
decoration: const InputDecoration(
|
||||
labelText: '평균 산정 기준',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
value: _selectedAverageCalculationPeriod,
|
||||
isExpanded: true,
|
||||
items: averageCalculationPeriodOptions.entries
|
||||
.map(
|
||||
(entry) => DropdownMenuItem<String>(
|
||||
value: entry.key,
|
||||
child: Text(entry.value),
|
||||
child: Text(
|
||||
entry.value,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
@@ -317,27 +339,18 @@ class _ClubSettingsScreenState extends State<ClubSettingsScreen> {
|
||||
),
|
||||
// 모임장 설정
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<String>(
|
||||
decoration: const InputDecoration(
|
||||
labelText: '모임장 설정',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
OwnerDropdown(
|
||||
key: const Key('owner_dropdown'),
|
||||
members: _members,
|
||||
value: _selectedOwnerId,
|
||||
items: _members.map((member) {
|
||||
return DropdownMenuItem<String>(
|
||||
value: member.userId,
|
||||
child: Text(
|
||||
'${member.name} (${getMemberTypeLabel(member.memberType)})',
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: _hasEditPermission
|
||||
? (value) {
|
||||
setState(() {
|
||||
_selectedOwnerId = value;
|
||||
});
|
||||
}
|
||||
: null,
|
||||
enabled: _hasEditPermission,
|
||||
onChanged: (value) {
|
||||
if (!_hasEditPermission) return;
|
||||
setState(() {
|
||||
_selectedOwnerId = value;
|
||||
});
|
||||
},
|
||||
onItemsBuilt: widget.onOwnerItemsBuilt,
|
||||
),
|
||||
// 저장 버튼
|
||||
const SizedBox(height: 24),
|
||||
|
||||
@@ -8,6 +8,7 @@ import '../../services/member_service.dart';
|
||||
import '../../services/event_service.dart';
|
||||
import '../../models/club_model.dart';
|
||||
import 'club_settings_screen.dart';
|
||||
import '../../theme/badges.dart';
|
||||
|
||||
class DashboardScreen extends StatefulWidget {
|
||||
const DashboardScreen({super.key});
|
||||
@@ -34,10 +35,15 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
// await 이전에 messenger 캡처 (catch에서 사용)
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
// await 이전에 서비스 인스턴스 캡처
|
||||
final authService = Provider.of<AuthService>(context, listen: false);
|
||||
final clubService = Provider.of<ClubService>(context, listen: false);
|
||||
final memberService = Provider.of<MemberService>(context, listen: false);
|
||||
final eventService = Provider.of<EventService>(context, listen: false);
|
||||
|
||||
try {
|
||||
final authService = Provider.of<AuthService>(context, listen: false);
|
||||
final clubService = Provider.of<ClubService>(context, listen: false);
|
||||
|
||||
// 클럽 정보 로드
|
||||
if (clubService.currentClub == null) {
|
||||
await clubService.initialize(authService.token!);
|
||||
@@ -45,9 +51,6 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
||||
|
||||
// 회원 및 이벤트 서비스 초기화
|
||||
if (clubService.currentClub != null) {
|
||||
final memberService = Provider.of<MemberService>(context, listen: false);
|
||||
final eventService = Provider.of<EventService>(context, listen: false);
|
||||
|
||||
memberService.initialize(authService.token!);
|
||||
eventService.initialize(authService.token!);
|
||||
|
||||
@@ -59,13 +62,15 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
||||
}
|
||||
} catch (e) {
|
||||
// 오류 처리
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('데이터 로드 중 오류가 발생했습니다: $e')),
|
||||
);
|
||||
} finally {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,11 +369,23 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 4),
|
||||
if (event.status != null) ...[
|
||||
Wrap(
|
||||
spacing: 4,
|
||||
runSpacing: 4,
|
||||
children: [
|
||||
BadgeStyles.eventStatus(event.status!),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
Text(
|
||||
dateFormat.format(event.startDate),
|
||||
style: TextStyle(
|
||||
@@ -381,6 +398,8 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
||||
style: TextStyle(
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,6 @@ import '../../utils/url_utils.dart';
|
||||
import '../../utils/test_utils.dart';
|
||||
|
||||
import '../../models/event_model.dart';
|
||||
import '../../services/auth_service.dart';
|
||||
import '../../services/event_service.dart';
|
||||
import '../../widgets/loading_indicator.dart';
|
||||
|
||||
@@ -26,8 +25,8 @@ class EventFormScreen extends StatefulWidget {
|
||||
class _EventFormScreenState extends State<EventFormScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
bool _isLoading = false;
|
||||
bool _isInit = false;
|
||||
bool _obscurePassword = true; // 비밀번호 숨김 상태 관리
|
||||
bool _testSaveMode = false; // 테스트 훅에서 저장 시 네비/스낵바 우회
|
||||
final _currencyFormat = NumberFormat.currency(locale: 'ko_KR', symbol: '₩', decimalDigits: 0);
|
||||
|
||||
// 폼 필드 컨트롤러
|
||||
@@ -114,6 +113,13 @@ class _EventFormScreenState extends State<EventFormScreen> {
|
||||
// 새 이벤트 생성 시 기본값 설정
|
||||
_type = _typeOptions[0];
|
||||
_status = _statusOptions[0];
|
||||
// 공개 해시를 동기로 초기화하여 초기 렌더 타이밍 변동을 줄임
|
||||
if (_publicHashController.text.isEmpty && !_isHashGenerated) {
|
||||
_isHashGenerated = true;
|
||||
final hash = UrlUtils.generatePublicHash();
|
||||
_publicHashController.text = hash;
|
||||
// 스낵바는 테스트에서 불필요하므로 생략, 일반 환경에서도 최초 자동 생성 시에는 표시하지 않음
|
||||
}
|
||||
}
|
||||
|
||||
// 참가비 입력 컨트롤러에 리스너 추가
|
||||
@@ -122,21 +128,7 @@ class _EventFormScreenState extends State<EventFormScreen> {
|
||||
|
||||
bool _isHashGenerated = false;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
|
||||
// 새 이벤트 생성 시에만 해시 자동 생성 (한 번만 실행되도록 플래그 사용)
|
||||
if (widget.event == null && _publicHashController.text.isEmpty && !_isHashGenerated) {
|
||||
_isHashGenerated = true;
|
||||
// 다음 프레임에서 해시 생성 (context 사용 문제 방지)
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
_generatePublicHash();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
// didChangeDependencies 사용 시 프레임 콜백에 의한 타이밍 변동이 생겨 테스트 플래키를 유발할 수 있어 제거
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
@@ -238,7 +230,6 @@ class _EventFormScreenState extends State<EventFormScreen> {
|
||||
|
||||
try {
|
||||
final eventService = Provider.of<EventService>(context, listen: false);
|
||||
final authService = Provider.of<AuthService>(context, listen: false);
|
||||
|
||||
// 이벤트 데이터 준비 - null 값 처리 개선
|
||||
final eventData = {
|
||||
@@ -271,47 +262,44 @@ class _EventFormScreenState extends State<EventFormScreen> {
|
||||
if (widget.event == null) {
|
||||
// 새 이벤트 생성
|
||||
await eventService.createEvent(eventData);
|
||||
if (mounted) {
|
||||
// 테스트 환경에서 안전하게 SnackBar 표시
|
||||
if (TestUtils.isInTest) {
|
||||
debugPrint('이벤트가 생성되었습니다');
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('이벤트가 생성되었습니다'),
|
||||
action: SnackBarAction(
|
||||
label: '확인',
|
||||
onPressed: () {},
|
||||
),
|
||||
duration: const Duration(seconds: 3),
|
||||
if (mounted && !_testSaveMode) {
|
||||
// 테스트가 아니면 스낵바 표시
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('이벤트가 생성되었습니다'),
|
||||
action: SnackBarAction(
|
||||
label: '확인',
|
||||
onPressed: () {},
|
||||
),
|
||||
);
|
||||
}
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// 기존 이벤트 수정
|
||||
await eventService.updateEvent(widget.event!.id, eventData);
|
||||
if (mounted) {
|
||||
// 테스트 환경에서 안전하게 SnackBar 표시
|
||||
if (TestUtils.isInTest) {
|
||||
debugPrint('이벤트가 수정되었습니다');
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('이벤트가 수정되었습니다'),
|
||||
action: SnackBarAction(
|
||||
label: '확인',
|
||||
onPressed: () {},
|
||||
),
|
||||
duration: const Duration(seconds: 3),
|
||||
if (mounted && !_testSaveMode) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('이벤트가 수정되었습니다'),
|
||||
action: SnackBarAction(
|
||||
label: '확인',
|
||||
onPressed: () {},
|
||||
),
|
||||
);
|
||||
}
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop(true); // 성공 결과와 함께 이전 화면으로 돌아가기
|
||||
// 성공 후 로딩 해제 (테스트/일반 공통)
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
if (!_testSaveMode) {
|
||||
Navigator.of(context).pop(true); // 성공 결과와 함께 이전 화면으로 돌아가기
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
@@ -331,6 +319,17 @@ class _EventFormScreenState extends State<EventFormScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
// 테스트 편의를 위한 공개 훅: 버튼 탭 없이 저장 플로우 직접 호출
|
||||
@visibleForTesting
|
||||
Future<void> invokeSaveForTest() async {
|
||||
_testSaveMode = true;
|
||||
try {
|
||||
await _saveEvent();
|
||||
} finally {
|
||||
_testSaveMode = false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 테스트 환경에서 애니메이션 비활성화
|
||||
@@ -427,6 +426,7 @@ class _EventFormScreenState extends State<EventFormScreen> {
|
||||
|
||||
// 이벤트 유형
|
||||
DropdownButtonFormField<String>(
|
||||
key: const Key('event_form_type_dropdown'),
|
||||
value: _type,
|
||||
decoration: InputDecoration(
|
||||
labelText: '이벤트 유형 *',
|
||||
@@ -463,6 +463,7 @@ class _EventFormScreenState extends State<EventFormScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
isExpanded: true,
|
||||
items: _typeOptions
|
||||
.map((type) => DropdownMenuItem<String>(
|
||||
value: type,
|
||||
@@ -477,13 +478,15 @@ class _EventFormScreenState extends State<EventFormScreen> {
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: _typeColors[type]?.withOpacity(0.2),
|
||||
color: _typeColors[type]?.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: _typeColors[type] ?? Colors.grey),
|
||||
),
|
||||
child: Text(
|
||||
type,
|
||||
style: TextStyle(color: _typeColors[type]),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -510,12 +513,12 @@ class _EventFormScreenState extends State<EventFormScreen> {
|
||||
margin: const EdgeInsets.only(left: 12),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: _typeColors[_type]?.withOpacity(0.2),
|
||||
color: _typeColors[_type]?.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: _typeColors[_type] ?? Colors.grey),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
blurRadius: 2,
|
||||
offset: const Offset(0, 1),
|
||||
),
|
||||
@@ -548,6 +551,7 @@ class _EventFormScreenState extends State<EventFormScreen> {
|
||||
|
||||
// 상태
|
||||
DropdownButtonFormField<String>(
|
||||
key: const Key('event_form_status_dropdown'),
|
||||
value: _status,
|
||||
decoration: InputDecoration(
|
||||
labelText: '상태 *',
|
||||
@@ -584,6 +588,7 @@ class _EventFormScreenState extends State<EventFormScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
isExpanded: true,
|
||||
items: _statusOptions
|
||||
.map((status) => DropdownMenuItem<String>(
|
||||
value: status,
|
||||
@@ -598,13 +603,15 @@ class _EventFormScreenState extends State<EventFormScreen> {
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: _statusColors[status]?.withOpacity(0.2),
|
||||
color: _statusColors[status]?.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: _statusColors[status] ?? Colors.grey),
|
||||
),
|
||||
child: Text(
|
||||
status,
|
||||
style: TextStyle(color: _statusColors[status]),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -631,12 +638,12 @@ class _EventFormScreenState extends State<EventFormScreen> {
|
||||
margin: const EdgeInsets.only(left: 12),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: _statusColors[_status]?.withOpacity(0.2),
|
||||
color: _statusColors[_status]?.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: _statusColors[_status] ?? Colors.grey),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
blurRadius: 2,
|
||||
offset: const Offset(0, 1),
|
||||
),
|
||||
@@ -665,6 +672,58 @@ class _EventFormScreenState extends State<EventFormScreen> {
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 공개 접근 섹션
|
||||
_buildSectionTitle('공개 접근'),
|
||||
Builder(
|
||||
builder: (ctx) {
|
||||
final hasHash = _publicHashController.text.trim().isNotEmpty;
|
||||
final publicUrl = hasHash
|
||||
? UrlUtils.getEventPublicUrl(_publicHashController.text.trim())
|
||||
: '';
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: TextEditingController(text: publicUrl),
|
||||
readOnly: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '공개 URL',
|
||||
hintText: '해시 생성 시 공개 URL이 표시됩니다',
|
||||
prefixIcon: Icon(Icons.link),
|
||||
),
|
||||
maxLines: 1,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: hasHash
|
||||
? () {
|
||||
final url = UrlUtils.getEventPublicUrl(_publicHashController.text.trim());
|
||||
UrlUtils.copyUrlToClipboard(context, url);
|
||||
}
|
||||
: null,
|
||||
icon: const Icon(Icons.copy),
|
||||
label: const Text('URL 복사'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _generatePublicHash,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: Text(hasHash ? '재생성' : '생성'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 날짜 및 시간 섹션
|
||||
_buildSectionTitle('날짜 및 시간'),
|
||||
|
||||
@@ -721,6 +780,7 @@ class _EventFormScreenState extends State<EventFormScreen> {
|
||||
);
|
||||
|
||||
if (date != null) {
|
||||
if (!context.mounted) return;
|
||||
final time = await showTimePicker(
|
||||
context: context,
|
||||
initialTime: TimeOfDay.fromDateTime(_startDate),
|
||||
@@ -775,6 +835,7 @@ class _EventFormScreenState extends State<EventFormScreen> {
|
||||
);
|
||||
|
||||
if (date != null) {
|
||||
if (!context.mounted) return;
|
||||
final time = await showTimePicker(
|
||||
context: context,
|
||||
initialTime: _endDate != null
|
||||
@@ -830,6 +891,7 @@ class _EventFormScreenState extends State<EventFormScreen> {
|
||||
);
|
||||
|
||||
if (date != null) {
|
||||
if (!context.mounted) return;
|
||||
final time = await showTimePicker(
|
||||
context: context,
|
||||
initialTime: _registrationDeadline != null
|
||||
@@ -1087,14 +1149,10 @@ class _EventFormScreenState extends State<EventFormScreen> {
|
||||
),
|
||||
Tooltip(
|
||||
message: _publicHashController.text.isEmpty ? '새 해시 생성' : '해시 재생성',
|
||||
child: ElevatedButton.icon(
|
||||
icon: const Icon(Icons.refresh, size: 18),
|
||||
label: Text(_publicHashController.text.isEmpty ? '생성' : '재생성'),
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
color: Colors.blue,
|
||||
onPressed: _generatePublicHash,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.blue,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -1104,9 +1162,9 @@ class _EventFormScreenState extends State<EventFormScreen> {
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.withOpacity(0.1),
|
||||
color: Colors.blue.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.blue.withOpacity(0.3)),
|
||||
border: Border.all(color: Colors.blue.withValues(alpha: 0.3)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -1117,7 +1175,7 @@ class _EventFormScreenState extends State<EventFormScreen> {
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'https://bowling.example.com/events/${_publicHashController.text}',
|
||||
UrlUtils.getEventPublicUrl(_publicHashController.text),
|
||||
style: TextStyle(color: Colors.blue[700]),
|
||||
),
|
||||
),
|
||||
@@ -1147,6 +1205,7 @@ class _EventFormScreenState extends State<EventFormScreen> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextFormField(
|
||||
key: const ValueKey('access_password_field'),
|
||||
controller: _accessPasswordController,
|
||||
decoration: InputDecoration(
|
||||
labelText: '접근 비밀번호 (선택)',
|
||||
@@ -1236,6 +1295,7 @@ class _EventFormScreenState extends State<EventFormScreen> {
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
key: const ValueKey('save_event_button'),
|
||||
onPressed: _saveEvent,
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
|
||||
import '../../utils/event_excel_parser.dart';
|
||||
import '../../utils/event_csv_parser.dart';
|
||||
import '../../services/auth_service.dart';
|
||||
import '../../services/club_service.dart';
|
||||
|
||||
@@ -13,6 +17,9 @@ import '../../widgets/loading_indicator.dart';
|
||||
import 'event_details_screen.dart';
|
||||
import 'event_form_screen.dart';
|
||||
import 'event_calendar_screen.dart';
|
||||
import '../../widgets/dialog_actions.dart';
|
||||
import '../../widgets/import_result_dialog.dart';
|
||||
import '../../theme/badges.dart';
|
||||
|
||||
class EventsScreen extends StatefulWidget {
|
||||
const EventsScreen({super.key});
|
||||
@@ -36,11 +43,185 @@ class _EventsScreenState extends State<EventsScreen> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 테스트 전용: 파일명을 포함한 바이트 데이터를 직접 주입해 업로드 플로우를 실행
|
||||
@visibleForTesting
|
||||
Future<void> importFromBytesForTest(String fileName, List<int> bytes) async {
|
||||
final ctx = context;
|
||||
final navigator = Navigator.of(ctx);
|
||||
final messenger = ScaffoldMessenger.of(ctx);
|
||||
final eventService = Provider.of<EventService>(ctx, listen: false);
|
||||
try {
|
||||
// 로딩 다이얼로그 표시
|
||||
if (!ctx.mounted) return;
|
||||
showDialog(
|
||||
context: ctx,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => const AlertDialog(
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 16),
|
||||
Text('파일 처리 중...'),
|
||||
SizedBox(height: 8),
|
||||
Text('이벤트 데이터를 추출하고 있습니다.'),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// 확장자에 따라 CSV/엑셀 파서 분기 + 안전 처리
|
||||
Map<String, dynamic> parseResult;
|
||||
final lower = fileName.toLowerCase();
|
||||
try {
|
||||
if (lower.endsWith('.csv')) {
|
||||
parseResult = EventCsvParser.parseCsvBytes(bytes);
|
||||
} else {
|
||||
parseResult = EventExcelParser.parseExcelBytes(bytes);
|
||||
}
|
||||
} catch (e) {
|
||||
navigator.pop();
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('파일 파싱 중 오류가 발생했습니다: ${e.toString()}\n가능하면 CSV 또는 XLSX 형식을 사용해 주세요.'),
|
||||
duration: Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
final processedRows = parseResult['processedRows'] as int;
|
||||
final events = (parseResult['validEvents'] as List).cast<Map<String, dynamic>>();
|
||||
final invalidRows = parseResult['invalidRows'] as int;
|
||||
final invalidRowIndices = (parseResult['invalidRowIndices'] as List<dynamic>? ?? const [])
|
||||
.map((e) => e.toString())
|
||||
.toList();
|
||||
final Map<String, String> invalidRowReasons = ((parseResult['invalidRowReasons'] as Map?) ?? const {})
|
||||
.map((key, value) => MapEntry(key.toString(), value?.toString() ?? ''));
|
||||
final error = parseResult['error'] as String?;
|
||||
|
||||
if (error != null) {
|
||||
navigator.pop();
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(error),
|
||||
duration: Duration(seconds: 4),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 파싱 성공 → 생성 로딩 다이얼로그로 전환
|
||||
if (!ctx.mounted) {
|
||||
if (navigator.canPop()) navigator.pop();
|
||||
return;
|
||||
}
|
||||
navigator.pop();
|
||||
if (!ctx.mounted) return;
|
||||
showDialog(
|
||||
context: ctx,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => AlertDialog(
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const CircularProgressIndicator(),
|
||||
const SizedBox(height: 16),
|
||||
Text('이벤트 ${events.length}개 생성 중...'),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final results = await eventService.createEventsFromFile(events);
|
||||
|
||||
// 생성 로딩 다이얼로그 닫기
|
||||
if (navigator.canPop()) navigator.pop();
|
||||
|
||||
// 결과 다이얼로그 표시
|
||||
if (!ctx.mounted) return;
|
||||
showDialog(
|
||||
context: ctx,
|
||||
builder: (dialogContext) => ImportResultDialog(
|
||||
fileName: fileName,
|
||||
processedRows: processedRows,
|
||||
createdCount: results.length,
|
||||
invalidRows: invalidRows,
|
||||
invalidRowIndices: invalidRowIndices,
|
||||
invalidRowReasons: invalidRowReasons,
|
||||
onClose: () {
|
||||
Navigator.of(dialogContext).pop();
|
||||
_loadEvents();
|
||||
},
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (navigator.canPop()) navigator.pop();
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('파일 처리 중 오류가 발생했습니다: $e'),
|
||||
duration: const Duration(seconds: 4),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 테스트 편의를 위한 공개 훅: 오버레이 없이 복제 플로우를 직접 호출
|
||||
@visibleForTesting
|
||||
Future<void> invokeDuplicateForTest(Event event) async {
|
||||
final eventService = Provider.of<EventService>(context, listen: false);
|
||||
eventService.setClubId(event.clubId);
|
||||
await eventService.cloneEvent(event.id);
|
||||
}
|
||||
|
||||
// 검색어 복원
|
||||
Future<void> _restoreSearchQuery() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final saved = prefs.getString('events_search_query') ?? '';
|
||||
if (saved.isNotEmpty) {
|
||||
_searchController.text = saved;
|
||||
_searchQuery = saved;
|
||||
}
|
||||
} catch (_) {
|
||||
// ignore restore errors
|
||||
}
|
||||
}
|
||||
|
||||
// 검색어 저장
|
||||
Future<void> _saveSearchQuery(String value) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString('events_search_query', value);
|
||||
} catch (_) {
|
||||
// ignore save errors
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _duplicateEvent(Event event) async {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
try {
|
||||
final eventService = Provider.of<EventService>(context, listen: false);
|
||||
// cloneEvent는 clubId가 필요하므로 보장 차원에서 주입
|
||||
eventService.setClubId(event.clubId);
|
||||
await eventService.cloneEvent(event.id);
|
||||
if (!mounted) return;
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(content: Text('이벤트가 복제되었습니다')),
|
||||
);
|
||||
// 목록 새로고침
|
||||
await _loadEvents();
|
||||
} catch (e) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('이벤트 복제에 실패했습니다: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (!_isInit) {
|
||||
_loadEvents();
|
||||
_restoreSearchQuery().then((_) => _loadEvents());
|
||||
_isInit = true;
|
||||
}
|
||||
}
|
||||
@@ -50,6 +231,8 @@ class _EventsScreenState extends State<EventsScreen> {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
// 비동기 갭 이후 컨텍스트 직접 참조를 피하기 위해 메신저 캡처
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
try {
|
||||
final authService = Provider.of<AuthService>(context, listen: false);
|
||||
final clubService = Provider.of<ClubService>(context, listen: false);
|
||||
@@ -60,11 +243,10 @@ class _EventsScreenState extends State<EventsScreen> {
|
||||
await eventService.fetchClubEvents();
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('이벤트 목록을 불러오는데 실패했습니다: $e')));
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('이벤트 목록을 불러오는데 실패했습니다: $e')),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
@@ -182,6 +364,7 @@ class _EventsScreenState extends State<EventsScreen> {
|
||||
setState(() {
|
||||
_searchQuery = value;
|
||||
});
|
||||
_saveSearchQuery(value);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
@@ -369,11 +552,23 @@ class _EventsScreenState extends State<EventsScreen> {
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isPast ? Colors.grey[600] : Colors.black,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 4),
|
||||
if (event.status != null) ...[
|
||||
Wrap(
|
||||
spacing: 4,
|
||||
runSpacing: 4,
|
||||
children: [
|
||||
BadgeStyles.eventStatus(event.status!),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
Text(
|
||||
dateFormat.format(event.startDate),
|
||||
style: TextStyle(
|
||||
@@ -386,16 +581,21 @@ class _EventsScreenState extends State<EventsScreen> {
|
||||
style: TextStyle(
|
||||
color: isPast ? Colors.grey[500] : Colors.grey[600],
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: PopupMenuButton<String>(
|
||||
key: ValueKey('event_menu_${event.id}'),
|
||||
icon: const Icon(Icons.more_vert),
|
||||
onSelected: (value) {
|
||||
if (value == 'edit') {
|
||||
_showEditEventDialog(event);
|
||||
} else if (value == 'delete') {
|
||||
_showDeleteConfirmationDialog(event);
|
||||
} else if (value == 'duplicate') {
|
||||
_duplicateEvent(event);
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
@@ -409,6 +609,16 @@ class _EventsScreenState extends State<EventsScreen> {
|
||||
],
|
||||
),
|
||||
),
|
||||
const PopupMenuItem<String>(
|
||||
value: 'duplicate',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.copy, size: 18),
|
||||
SizedBox(width: 8),
|
||||
Text('복제'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const PopupMenuItem<String>(
|
||||
value: 'delete',
|
||||
child: Row(
|
||||
@@ -526,57 +736,57 @@ class _EventsScreenState extends State<EventsScreen> {
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
if (titleController.text.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('제목은 필수 입력 항목입니다')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
actions: DialogActions.confirm(
|
||||
onCancel: () => Navigator.of(context).pop(),
|
||||
onConfirm: () async {
|
||||
if (titleController.text.isEmpty) {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('제목은 필수 입력 항목입니다')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final eventService = Provider.of<EventService>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
try {
|
||||
final eventService = Provider.of<EventService>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
|
||||
// 날짜와 시간 결합
|
||||
final eventDateTime = DateTime(
|
||||
selectedDate.year,
|
||||
selectedDate.month,
|
||||
selectedDate.day,
|
||||
selectedTime.hour,
|
||||
selectedTime.minute,
|
||||
);
|
||||
// 날짜와 시간 결합
|
||||
final eventDateTime = DateTime(
|
||||
selectedDate.year,
|
||||
selectedDate.month,
|
||||
selectedDate.day,
|
||||
selectedTime.hour,
|
||||
selectedTime.minute,
|
||||
);
|
||||
|
||||
await eventService.updateEvent(event.id, {
|
||||
'title': titleController.text.trim(),
|
||||
'description': descriptionController.text.trim(),
|
||||
'location': locationController.text.trim(),
|
||||
'startDate': eventDateTime.toIso8601String(),
|
||||
});
|
||||
await eventService.updateEvent(event.id, {
|
||||
'title': titleController.text.trim(),
|
||||
'description': descriptionController.text.trim().isEmpty
|
||||
? null
|
||||
: descriptionController.text.trim(),
|
||||
'location': locationController.text.trim().isEmpty
|
||||
? null
|
||||
: locationController.text.trim(),
|
||||
'startDate': eventDateTime.toIso8601String(),
|
||||
});
|
||||
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('이벤트가 업데이트되었습니다')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('이벤트 업데이트에 실패했습니다: $e')));
|
||||
}
|
||||
},
|
||||
child: const Text('저장'),
|
||||
),
|
||||
],
|
||||
if (!context.mounted) return;
|
||||
Navigator.of(context).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('이벤트가 업데이트되었습니다')),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('이벤트 업데이트에 실패했습니다: $e')),
|
||||
);
|
||||
}
|
||||
},
|
||||
confirmText: '저장',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -602,37 +812,30 @@ class _EventsScreenState extends State<EventsScreen> {
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('이벤트 삭제'),
|
||||
content: Text('${event.title} 이벤트를 정말 삭제하시겠습니까?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
try {
|
||||
final eventService = Provider.of<EventService>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
|
||||
await eventService.deleteEvent(event.id);
|
||||
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop();
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('이벤트가 삭제되었습니다')));
|
||||
}
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('이벤트 삭제에 실패했습니다: $e')));
|
||||
}
|
||||
},
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
child: const Text('삭제'),
|
||||
),
|
||||
],
|
||||
actions: DialogActions.confirm(
|
||||
onCancel: () => Navigator.of(context).pop(),
|
||||
onConfirm: () async {
|
||||
try {
|
||||
final eventService = Provider.of<EventService>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
await eventService.deleteEvent(event.id);
|
||||
if (!context.mounted) return;
|
||||
Navigator.of(context).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('이벤트가 삭제되었습니다')),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('이벤트 삭제에 실패했습니다: $e')),
|
||||
);
|
||||
}
|
||||
},
|
||||
destructive: true,
|
||||
confirmText: '삭제',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -696,36 +899,56 @@ class _EventsScreenState extends State<EventsScreen> {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
// 여기서는 예시 템플릿을 제공하는 기능을 구현할 수 있습니다.
|
||||
// 현재는 안내 메시지만 표시합니다.
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('예시 템플릿 준비 중입니다.')),
|
||||
onPressed: () async {
|
||||
// 번들된 CSV 템플릿을 읽어 공유
|
||||
final data = await rootBundle.loadString('assets/templates/event_import_template.csv');
|
||||
await SharePlus.instance.share(
|
||||
ShareParams(
|
||||
text: data,
|
||||
subject: 'Event Import Template.csv',
|
||||
),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.download),
|
||||
label: const Text('예시 템플릿 다운로드'),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
TextButton.icon(
|
||||
onPressed: () async {
|
||||
const headers = 'title,startDate,endDate,description,location,status,type,maxParticipants';
|
||||
await Clipboard.setData(const ClipboardData(text: headers));
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('CSV 헤더가 클립보드에 복사되었습니다.')),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.copy_all),
|
||||
label: const Text('CSV 헤더 복사'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('닫기'),
|
||||
),
|
||||
],
|
||||
actions: DialogActions.confirm(
|
||||
onCancel: () => Navigator.of(context).pop(),
|
||||
onConfirm: () => Navigator.of(context).pop(),
|
||||
confirmText: '닫기',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 파일 선택 및 업로드
|
||||
Future<void> _pickAndUploadFile() async {
|
||||
// 비동기 전 context 및 파생 객체 캡처 (use_build_context_synchronously 회피)
|
||||
final ctx = context;
|
||||
final navigator = Navigator.of(ctx);
|
||||
final messenger = ScaffoldMessenger.of(ctx);
|
||||
final eventService = Provider.of<EventService>(ctx, listen: false);
|
||||
try {
|
||||
// 파일 피커를 사용하여 엑셀 파일 선택
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['xlsx', 'xls'],
|
||||
allowedExtensions: ['xlsx', 'xls', 'csv'],
|
||||
allowMultiple: false,
|
||||
);
|
||||
|
||||
@@ -739,7 +962,7 @@ class _EventsScreenState extends State<EventsScreen> {
|
||||
final bytes = file.bytes;
|
||||
|
||||
if (bytes == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('파일을 읽을 수 없습니다. 다른 파일을 선택해주세요.'),
|
||||
duration: Duration(seconds: 3),
|
||||
@@ -749,10 +972,11 @@ class _EventsScreenState extends State<EventsScreen> {
|
||||
}
|
||||
|
||||
// 로딩 다이얼로그 표시
|
||||
if (!ctx.mounted) return;
|
||||
showDialog(
|
||||
context: context,
|
||||
context: ctx,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => AlertDialog(
|
||||
builder: (_) => AlertDialog(
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
@@ -766,17 +990,40 @@ class _EventsScreenState extends State<EventsScreen> {
|
||||
),
|
||||
);
|
||||
|
||||
// EventExcelParser를 사용하여 엑셀 파일 파싱
|
||||
final parseResult = EventExcelParser.parseExcelBytes(bytes);
|
||||
// 확장자에 따라 CSV/엑셀 파서 분기 + 안전 처리
|
||||
Map<String, dynamic> parseResult;
|
||||
final lower = fileName.toLowerCase();
|
||||
try {
|
||||
if (lower.endsWith('.csv')) {
|
||||
parseResult = EventCsvParser.parseCsvBytes(bytes);
|
||||
} else {
|
||||
parseResult = EventExcelParser.parseExcelBytes(bytes);
|
||||
}
|
||||
} catch (e) {
|
||||
navigator.pop();
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('파일 파싱 중 오류가 발생했습니다: ${e.toString()}\n가능하면 CSV 또는 XLSX 형식을 사용해 주세요.'),
|
||||
duration: const Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
final processedRows = parseResult['processedRows'] as int;
|
||||
final events = parseResult['validEvents'] as List<Map<String, dynamic>>;
|
||||
final invalidRows = parseResult['invalidRows'] as int;
|
||||
final invalidRowIndices = (parseResult['invalidRowIndices'] as List<dynamic>? ?? const [])
|
||||
.map((e) => e.toString())
|
||||
.toList();
|
||||
final Map<String, String> invalidRowReasons = ((parseResult['invalidRowReasons'] as Map?) ?? const {})
|
||||
.map((key, value) => MapEntry(key.toString(), value?.toString() ?? ''));
|
||||
final error = parseResult['error'] as String?;
|
||||
|
||||
// 오류가 있거나 이벤트가 없는 경우
|
||||
if (error != null) {
|
||||
Navigator.of(context).pop(); // 로딩 다이얼로그 닫기
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
// 로딩 다이얼로그 닫기 및 에러 스낵바 표시
|
||||
navigator.pop();
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(error),
|
||||
duration: const Duration(seconds: 4),
|
||||
@@ -786,68 +1033,58 @@ class _EventsScreenState extends State<EventsScreen> {
|
||||
}
|
||||
|
||||
// 로딩 다이얼로그 업데이트
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop();
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => AlertDialog(
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const CircularProgressIndicator(),
|
||||
const SizedBox(height: 16),
|
||||
Text('이벤트 ${events.length}개 생성 중...'),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
if (!ctx.mounted) {
|
||||
if (navigator.canPop()) navigator.pop();
|
||||
return;
|
||||
}
|
||||
navigator.pop();
|
||||
if (!ctx.mounted) return;
|
||||
showDialog(
|
||||
context: ctx,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => AlertDialog(
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const CircularProgressIndicator(),
|
||||
const SizedBox(height: 16),
|
||||
Text('이벤트 ${events.length}개 생성 중...'),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// 이벤트 서비스를 통해 이벤트 생성
|
||||
final eventService = Provider.of<EventService>(context, listen: false);
|
||||
// 이벤트 서비스를 통해 이벤트 생성 (비동기 이전에 캡처해 둔 것을 사용)
|
||||
final results = await eventService.createEventsFromFile(events);
|
||||
|
||||
// 로딩 다이얼로그 닫기
|
||||
if (mounted) Navigator.of(context).pop();
|
||||
if (navigator.canPop()) navigator.pop();
|
||||
|
||||
// 결과 표시
|
||||
if (mounted) {
|
||||
// 상세 결과 다이얼로그 표시
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('파일 처리 결과'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('파일명: $fileName'),
|
||||
const SizedBox(height: 8),
|
||||
Text('처리된 행: $processedRows개'),
|
||||
Text('생성된 이벤트: ${results.length}개'),
|
||||
if (invalidRows > 0) Text('유효하지 않은 행: $invalidRows개'),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
// 이벤트 목록 새로고침
|
||||
_loadEvents();
|
||||
},
|
||||
child: const Text('확인'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
if (!ctx.mounted) return;
|
||||
// 상세 결과 다이얼로그 표시
|
||||
if (!ctx.mounted) return;
|
||||
showDialog(
|
||||
context: ctx,
|
||||
builder: (dialogContext) => ImportResultDialog(
|
||||
fileName: fileName,
|
||||
processedRows: processedRows,
|
||||
createdCount: results.length,
|
||||
invalidRows: invalidRows,
|
||||
invalidRowIndices: invalidRowIndices,
|
||||
invalidRowReasons: invalidRowReasons,
|
||||
onClose: () {
|
||||
Navigator.of(dialogContext).pop();
|
||||
_loadEvents();
|
||||
},
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
// 로딩 다이얼로그가 열려 있으면 닫기
|
||||
if (mounted) Navigator.of(context).pop();
|
||||
if (navigator.canPop()) navigator.pop();
|
||||
|
||||
// 사용자 친화적인 오류 메시지 표시
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
// 사용자 친화적인 오류 메시지 표시 (messenger는 사전 캡처되어 context 불필요)
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('파일 처리 중 오류가 발생했습니다: $e'),
|
||||
duration: const Duration(seconds: 4),
|
||||
|
||||
@@ -7,6 +7,8 @@ import '../../services/club_service.dart';
|
||||
import '../../services/member_service.dart';
|
||||
import '../../models/member_model.dart';
|
||||
import '../../utils/enum_mappings.dart';
|
||||
import '../../widgets/dialog_actions.dart';
|
||||
import '../../theme/badges.dart';
|
||||
|
||||
class MembersScreen extends StatefulWidget {
|
||||
const MembersScreen({super.key});
|
||||
@@ -36,6 +38,8 @@ class _MembersScreenState extends State<MembersScreen> {
|
||||
}
|
||||
|
||||
Future<void> _loadMembers() async {
|
||||
// 비동기 이후 컨텍스트 직접 참조를 피하기 위해 메신저를 미리 캡처
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
try {
|
||||
print('_loadMembers 호출됨');
|
||||
final authService = Provider.of<AuthService>(context, listen: false);
|
||||
@@ -59,11 +63,9 @@ class _MembersScreenState extends State<MembersScreen> {
|
||||
}
|
||||
} catch (e) {
|
||||
print('회원 목록 가져오기 오류: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('회원 목록을 불러오는데 실패했습니다: $e')),
|
||||
);
|
||||
}
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('회원 목록을 불러오는데 실패했습니다: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,6 +195,8 @@ class _MembersScreenState extends State<MembersScreen> {
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// 성별 아이콘
|
||||
@@ -230,8 +234,10 @@ class _MembersScreenState extends State<MembersScreen> {
|
||||
fontSize: 14,
|
||||
color: Colors.grey.shade700,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const SizedBox(height: 2),
|
||||
if (member.email.isNotEmpty)
|
||||
Text(
|
||||
member.email,
|
||||
@@ -239,19 +245,17 @@ class _MembersScreenState extends State<MembersScreen> {
|
||||
fontSize: 14,
|
||||
color: Colors.grey.shade700,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 4,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
memberTypeText, // 회원 유형으로 변경
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.blue.shade700,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// 회원 유형 배지 (공통 스타일 적용)
|
||||
BadgeStyles.memberType(memberTypeText),
|
||||
// 가입일 표시
|
||||
if (member.joinDate != null)
|
||||
Text(
|
||||
@@ -261,23 +265,8 @@ class _MembersScreenState extends State<MembersScreen> {
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// 상태 표시
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: getStatusColor(member.status ?? 'inactive'),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
getStatusLabel(member.status),
|
||||
style: TextStyle(
|
||||
color: getStatusTextColor(member.status ?? 'inactive'),
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
// 상태 표시 (공통 배지 적용)
|
||||
BadgeStyles.memberStatus(getStatusLabel(member.status)),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -328,8 +317,8 @@ class _MembersScreenState extends State<MembersScreen> {
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
builder: (dialogContext) => StatefulBuilder(
|
||||
builder: (dialogContext, setState) {
|
||||
return AlertDialog(
|
||||
title: const Text('회원 추가'),
|
||||
content: SingleChildScrollView(
|
||||
@@ -395,14 +384,16 @@ class _MembersScreenState extends State<MembersScreen> {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<String>(
|
||||
key: const Key('members_add_gender_dropdown'),
|
||||
value: selectedGender,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '성별',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: const [
|
||||
DropdownMenuItem(value: 'male', child: Text('남성')),
|
||||
DropdownMenuItem(value: 'female', child: Text('여성')),
|
||||
isExpanded: true,
|
||||
items: [
|
||||
DropdownMenuItem(value: 'male', child: Text('남성', maxLines: 1, overflow: TextOverflow.ellipsis)),
|
||||
DropdownMenuItem(value: 'female', child: Text('여성', maxLines: 1, overflow: TextOverflow.ellipsis)),
|
||||
],
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
@@ -414,15 +405,17 @@ class _MembersScreenState extends State<MembersScreen> {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<String>(
|
||||
key: const Key('members_add_member_type_dropdown'),
|
||||
value: selectedMemberType,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '회원 유형',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: const [
|
||||
DropdownMenuItem(value: 'regular', child: Text('정회원')),
|
||||
DropdownMenuItem(value: 'associate', child: Text('준회원')),
|
||||
DropdownMenuItem(value: 'guest', child: Text('게스트')),
|
||||
isExpanded: true,
|
||||
items: [
|
||||
DropdownMenuItem(value: 'regular', child: Text('정회원', maxLines: 1, overflow: TextOverflow.ellipsis)),
|
||||
DropdownMenuItem(value: 'associate', child: Text('준회원', maxLines: 1, overflow: TextOverflow.ellipsis)),
|
||||
DropdownMenuItem(value: 'guest', child: Text('게스트', maxLines: 1, overflow: TextOverflow.ellipsis)),
|
||||
],
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
@@ -434,14 +427,16 @@ class _MembersScreenState extends State<MembersScreen> {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<String>(
|
||||
key: const Key('members_add_status_dropdown'),
|
||||
value: selectedStatus,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '상태',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: const [
|
||||
DropdownMenuItem(value: 'active', child: Text('활성')),
|
||||
DropdownMenuItem(value: 'inactive', child: Text('비활성')),
|
||||
isExpanded: true,
|
||||
items: [
|
||||
DropdownMenuItem(value: 'active', child: Text('활성', maxLines: 1, overflow: TextOverflow.ellipsis)),
|
||||
DropdownMenuItem(value: 'inactive', child: Text('비활성', maxLines: 1, overflow: TextOverflow.ellipsis)),
|
||||
],
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
@@ -454,78 +449,65 @@ class _MembersScreenState extends State<MembersScreen> {
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
if (nameController.text.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('이름은 필수 입력 항목입니다')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final memberService = Provider.of<MemberService>(context, listen: false);
|
||||
final clubService = Provider.of<ClubService>(context, listen: false);
|
||||
|
||||
if (clubService.currentClub != null) {
|
||||
// 회원 추가 데이터 준비
|
||||
final memberData = <String, dynamic>{
|
||||
'name': nameController.text.trim(),
|
||||
'clubId': clubService.currentClub!.id,
|
||||
'gender': selectedGender,
|
||||
'memberType': selectedMemberType,
|
||||
'status': selectedStatus,
|
||||
'handicap': int.tryParse(handicapController.text) ?? 0,
|
||||
'isActive': selectedStatus == 'active',
|
||||
};
|
||||
|
||||
// 가입일 추가
|
||||
try {
|
||||
final joinDate = parseDate(joinDateController.text);
|
||||
if (joinDate != null) {
|
||||
memberData['joinDate'] = joinDate.toIso8601String();
|
||||
}
|
||||
} catch (e) {
|
||||
// 가입일 파싱 오류 무시
|
||||
}
|
||||
|
||||
// 이메일이 비어있으면 null로 설정, 그렇지 않으면 트림한 값 사용
|
||||
if (emailController.text.trim().isEmpty) {
|
||||
memberData['email'] = null;
|
||||
} else {
|
||||
memberData['email'] = emailController.text.trim();
|
||||
}
|
||||
|
||||
// 전화번호가 비어있으면 null로 설정, 그렇지 않으면 트림한 값 사용
|
||||
if (phoneController.text.trim().isEmpty) {
|
||||
memberData['phone'] = null;
|
||||
} else {
|
||||
memberData['phone'] = phoneController.text.trim();
|
||||
}
|
||||
|
||||
await memberService.addMember(memberData);
|
||||
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('회원이 추가되었습니다')),
|
||||
);
|
||||
actions: DialogActions.confirm(
|
||||
onCancel: () => Navigator.of(dialogContext).pop(),
|
||||
onConfirm: () async {
|
||||
// await 이전에 컨텍스트 파생 객체 캡처
|
||||
final navigator = Navigator.of(dialogContext);
|
||||
final messenger = ScaffoldMessenger.of(dialogContext);
|
||||
if (nameController.text.isEmpty) {
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(content: Text('이름은 필수 입력 항목입니다')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final memberService = Provider.of<MemberService>(context, listen: false);
|
||||
final clubService = Provider.of<ClubService>(context, listen: false);
|
||||
|
||||
if (clubService.currentClub != null) {
|
||||
// 회원 추가 데이터 준비
|
||||
final memberData = <String, dynamic>{
|
||||
'name': nameController.text.trim(),
|
||||
'clubId': clubService.currentClub!.id,
|
||||
'gender': selectedGender,
|
||||
'memberType': selectedMemberType,
|
||||
'status': selectedStatus,
|
||||
'handicap': int.tryParse(handicapController.text) ?? 0,
|
||||
'isActive': selectedStatus == 'active',
|
||||
};
|
||||
|
||||
// 가입일 추가
|
||||
try {
|
||||
final joinDate = parseDate(joinDateController.text);
|
||||
if (joinDate != null) {
|
||||
memberData['joinDate'] = joinDate.toIso8601String();
|
||||
}
|
||||
} catch (e) {
|
||||
// 가입일 파싱 오류 무시
|
||||
}
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('회원 추가에 실패했습니다: $e')),
|
||||
|
||||
// 이메일이 비어있으면 null로 설정, 그렇지 않으면 트림한 값 사용
|
||||
memberData['email'] = emailController.text.trim().isEmpty ? null : emailController.text.trim();
|
||||
// 전화번호가 비어있으면 null로 설정, 그렇지 않으면 트림한 값 사용
|
||||
memberData['phone'] = phoneController.text.trim().isEmpty ? null : phoneController.text.trim();
|
||||
|
||||
await memberService.addMember(memberData);
|
||||
// 결과 알림 및 다이얼로그 닫기
|
||||
navigator.pop();
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(content: Text('회원이 추가되었습니다')),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Text('추가'),
|
||||
),
|
||||
],
|
||||
} catch (e) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('회원 추가에 실패했습니다: $e')),
|
||||
);
|
||||
}
|
||||
},
|
||||
confirmText: '추가',
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -619,7 +601,7 @@ class _MembersScreenState extends State<MembersScreen> {
|
||||
// 폼 컨트롤러 초기화
|
||||
final nameController = TextEditingController(text: member.name);
|
||||
final phoneController = TextEditingController(text: member.phone ?? '');
|
||||
final emailController = TextEditingController(text: member.email ?? '');
|
||||
final emailController = TextEditingController(text: member.email);
|
||||
final handicapController = TextEditingController(text: member.handicap?.toString() ?? '0');
|
||||
final joinDateController = TextEditingController(text: formatDate(member.joinDate));
|
||||
|
||||
@@ -630,8 +612,8 @@ class _MembersScreenState extends State<MembersScreen> {
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
builder: (dialogContext) => StatefulBuilder(
|
||||
builder: (dialogContext, setState) {
|
||||
return AlertDialog(
|
||||
title: Text(isEditing ? '회원 정보 수정' : '회원 정보'),
|
||||
content: SingleChildScrollView(
|
||||
@@ -718,19 +700,21 @@ class _MembersScreenState extends State<MembersScreen> {
|
||||
// 성별 필드 - 상단에 없으므로 항상 표시
|
||||
buildFormField('성별', isEditing ?
|
||||
DropdownButtonFormField<String>(
|
||||
key: const Key('members_edit_gender_dropdown'),
|
||||
value: selectedGender,
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
),
|
||||
isExpanded: true,
|
||||
items: [
|
||||
DropdownMenuItem<String>(
|
||||
value: 'male',
|
||||
child: Text('남성'),
|
||||
child: Text('남성', maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
DropdownMenuItem<String>(
|
||||
value: 'female',
|
||||
child: Text('여성'),
|
||||
child: Text('여성', maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
],
|
||||
onChanged: (value) {
|
||||
@@ -745,31 +729,33 @@ class _MembersScreenState extends State<MembersScreen> {
|
||||
// 회원 유형 필드 - 수정 모드에서만 표시 (상단에 이미 표시됨)
|
||||
if (isEditing)
|
||||
buildFormField('회원 유형', DropdownButtonFormField<String>(
|
||||
key: const Key('members_edit_member_type_dropdown'),
|
||||
value: selectedMemberType,
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
),
|
||||
isExpanded: true,
|
||||
items: [
|
||||
DropdownMenuItem<String>(
|
||||
value: 'regular',
|
||||
child: Text('정회원'),
|
||||
child: Text('정회원', maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
DropdownMenuItem<String>(
|
||||
value: 'associate',
|
||||
child: Text('준회원'),
|
||||
child: Text('준회원', maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
DropdownMenuItem<String>(
|
||||
value: 'guest',
|
||||
child: Text('게스트'),
|
||||
child: Text('게스트', maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
DropdownMenuItem<String>(
|
||||
value: 'manager',
|
||||
child: Text('운영진'),
|
||||
child: Text('운영진', maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
DropdownMenuItem<String>(
|
||||
value: 'owner',
|
||||
child: Text('모임장'),
|
||||
child: Text('모임장', maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
],
|
||||
onChanged: (value) {
|
||||
@@ -817,23 +803,25 @@ class _MembersScreenState extends State<MembersScreen> {
|
||||
// 상태 필드 - 수정 모드에서만 표시 (상단에 이미 표시됨)
|
||||
if (isEditing)
|
||||
buildFormField('상태', DropdownButtonFormField<String>(
|
||||
key: const Key('members_edit_status_dropdown'),
|
||||
value: selectedStatus,
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
),
|
||||
isExpanded: true,
|
||||
items: [
|
||||
DropdownMenuItem<String>(
|
||||
value: 'active',
|
||||
child: Text('활성'),
|
||||
child: Text('활성', maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
DropdownMenuItem<String>(
|
||||
value: 'inactive',
|
||||
child: Text('비활성'),
|
||||
child: Text('비활성', maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
DropdownMenuItem<String>(
|
||||
value: 'suspended',
|
||||
child: Text('정지'),
|
||||
child: Text('정지', maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
],
|
||||
onChanged: (value) {
|
||||
@@ -848,7 +836,7 @@ class _MembersScreenState extends State<MembersScreen> {
|
||||
InkWell(
|
||||
onTap: () async {
|
||||
final DateTime? picked = await showDatePicker(
|
||||
context: context,
|
||||
context: dialogContext,
|
||||
initialDate: member.joinDate ?? DateTime.now(),
|
||||
firstDate: DateTime(2000),
|
||||
lastDate: DateTime.now(),
|
||||
@@ -900,16 +888,19 @@ class _MembersScreenState extends State<MembersScreen> {
|
||||
actions: [
|
||||
// 취소 버튼
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
// 수정/저장 버튼
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
// 비동기 작업 전 컨텍스트 파생 객체 캡처
|
||||
final navigator = Navigator.of(dialogContext);
|
||||
final messenger = ScaffoldMessenger.of(dialogContext);
|
||||
if (isEditing) {
|
||||
// 수정 모드에서 저장 버튼 클릭 시
|
||||
if (nameController.text.trim().isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(content: Text('이름은 필수 입력 항목입니다.')),
|
||||
);
|
||||
return;
|
||||
@@ -952,12 +943,12 @@ class _MembersScreenState extends State<MembersScreen> {
|
||||
updatedMember.id,
|
||||
memberData,
|
||||
).then((_) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(content: Text('회원 정보가 업데이트되었습니다.')),
|
||||
);
|
||||
Navigator.of(context).pop();
|
||||
navigator.pop();
|
||||
}).catchError((error) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('오류: ${error.toString()}')),
|
||||
);
|
||||
});
|
||||
@@ -981,37 +972,31 @@ class _MembersScreenState extends State<MembersScreen> {
|
||||
void _showDeleteConfirmationDialog(Member member) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('회원 삭제'),
|
||||
content: Text('${member.name} 회원을 정말 삭제하시겠습니까?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
try {
|
||||
final memberService = Provider.of<MemberService>(context, listen: false);
|
||||
|
||||
await memberService.deleteMember(member.id);
|
||||
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('회원이 삭제되었습니다')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('회원 삭제에 실패했습니다: $e')),
|
||||
);
|
||||
}
|
||||
},
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
child: const Text('삭제'),
|
||||
),
|
||||
],
|
||||
actions: DialogActions.confirm(
|
||||
onCancel: () => Navigator.of(dialogContext).pop(),
|
||||
onConfirm: () async {
|
||||
// await 이전에 컨텍스트 파생 객체 캡처
|
||||
final navigator = Navigator.of(dialogContext);
|
||||
final messenger = ScaffoldMessenger.of(dialogContext);
|
||||
try {
|
||||
final memberService = Provider.of<MemberService>(context, listen: false);
|
||||
await memberService.deleteMember(member.id);
|
||||
navigator.pop();
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(content: Text('회원이 삭제되었습니다')),
|
||||
);
|
||||
} catch (e) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('회원 삭제에 실패했습니다: $e')),
|
||||
);
|
||||
}
|
||||
},
|
||||
destructive: true,
|
||||
confirmText: '삭제',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../models/participant_model.dart';
|
||||
import '../../services/event_service.dart';
|
||||
import '../../widgets/loading_indicator.dart';
|
||||
import '../../utils/enum_mappings.dart';
|
||||
|
||||
class ParticipantFormScreen extends StatefulWidget {
|
||||
final String eventId;
|
||||
@@ -35,13 +35,22 @@ class _ParticipantFormScreenState extends State<ParticipantFormScreen> {
|
||||
String? _status;
|
||||
bool _isPaid = false;
|
||||
|
||||
// 참가자 상태 옵션
|
||||
final List<String> _statusOptions = ['등록됨', '확인됨', '취소됨', '참석함'];
|
||||
// 참가자 상태 옵션 (enum_mappings 사용)
|
||||
late final List<String> _statusKoOptions;
|
||||
late final Map<String, String> _statusEnToKo;
|
||||
late final Map<String, String> _statusKoToEn;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// enum 매핑 구성
|
||||
_statusEnToKo = Map<String, String>.from(participantStatusOptions);
|
||||
_statusKoToEn = {
|
||||
for (final e in participantStatusOptions.entries) e.value: e.key,
|
||||
};
|
||||
_statusKoOptions = participantStatusOptions.values.toList(growable: false);
|
||||
|
||||
// 수정 모드인 경우 기존 데이터 로드
|
||||
if (widget.participant != null) {
|
||||
_nameController.text = widget.participant!.name ?? '';
|
||||
@@ -50,11 +59,14 @@ class _ParticipantFormScreenState extends State<ParticipantFormScreen> {
|
||||
_notesController.text = widget.participant!.notes ?? '';
|
||||
_paidAmountController.text = widget.participant!.paidAmount?.toString() ?? '';
|
||||
|
||||
_status = widget.participant!.status;
|
||||
// 서버/모델 상태(영문)를 UI 드롭다운(한글)으로 변환
|
||||
final currentStatusEn = widget.participant!.status ?? 'pending';
|
||||
final currentStatusKo = _statusEnToKo[currentStatusEn] ?? _statusKoOptions.first;
|
||||
_status = _statusKoOptions.contains(currentStatusKo) ? currentStatusKo : _statusKoOptions.first;
|
||||
_isPaid = widget.participant!.isPaid;
|
||||
} else {
|
||||
// 새 참가자 추가 시 기본값 설정
|
||||
_status = _statusOptions[0];
|
||||
_status = _statusKoOptions.first; // pending
|
||||
_isPaid = false;
|
||||
}
|
||||
}
|
||||
@@ -88,8 +100,11 @@ class _ParticipantFormScreenState extends State<ParticipantFormScreen> {
|
||||
'name': _nameController.text.trim(),
|
||||
'email': _emailController.text.trim().isEmpty ? null : _emailController.text.trim(),
|
||||
'phoneNumber': _phoneNumberController.text.trim().isEmpty ? null : _phoneNumberController.text.trim(),
|
||||
'status': _status,
|
||||
// 서버는 영문 상태를 기대, UI는 한글 상태를 사용하므로 변환 (pending/confirmed/canceled)
|
||||
'status': _statusKoToEn[_status ?? _statusKoOptions.first] ?? 'pending',
|
||||
// 결제 상태는 enum 키 사용 (unpaid/paid)
|
||||
'isPaid': _isPaid,
|
||||
'paymentStatus': _isPaid ? 'paid' : 'unpaid',
|
||||
'paidAmount': _paidAmountController.text.isEmpty ? null : double.parse(_paidAmountController.text),
|
||||
'notes': _notesController.text.trim().isEmpty ? null : _notesController.text.trim(),
|
||||
};
|
||||
@@ -149,9 +164,18 @@ class _ParticipantFormScreenState extends State<ParticipantFormScreen> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 게스트 사전입력 버튼
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _showGuestPrefillSheet,
|
||||
icon: const Icon(Icons.person_add),
|
||||
label: const Text('게스트 사전입력'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// 기본 정보 섹션
|
||||
_buildSectionTitle('기본 정보'),
|
||||
|
||||
// 이름
|
||||
TextFormField(
|
||||
controller: _nameController,
|
||||
@@ -167,7 +191,6 @@ class _ParticipantFormScreenState extends State<ParticipantFormScreen> {
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 이메일
|
||||
TextFormField(
|
||||
controller: _emailController,
|
||||
@@ -178,7 +201,6 @@ class _ParticipantFormScreenState extends State<ParticipantFormScreen> {
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
validator: (value) {
|
||||
if (value != null && value.isNotEmpty) {
|
||||
// 간단한 이메일 형식 검증
|
||||
if (!value.contains('@') || !value.contains('.')) {
|
||||
return '유효한 이메일 주소를 입력해주세요';
|
||||
}
|
||||
@@ -187,7 +209,6 @@ class _ParticipantFormScreenState extends State<ParticipantFormScreen> {
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 전화번호
|
||||
TextFormField(
|
||||
controller: _phoneNumberController,
|
||||
@@ -198,22 +219,22 @@ class _ParticipantFormScreenState extends State<ParticipantFormScreen> {
|
||||
keyboardType: TextInputType.phone,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 상태 섹션
|
||||
_buildSectionTitle('상태 정보'),
|
||||
|
||||
// 참가자 상태
|
||||
DropdownButtonFormField<String>(
|
||||
value: _status,
|
||||
key: const Key('participant_form_status_dropdown'),
|
||||
value: _statusKoOptions.contains(_status) ? _status : _statusKoOptions.first,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '참가자 상태',
|
||||
),
|
||||
items: _statusOptions.map((status) {
|
||||
return DropdownMenuItem(
|
||||
value: status,
|
||||
child: Text(status),
|
||||
);
|
||||
}).toList(),
|
||||
isExpanded: true,
|
||||
items: _statusKoOptions
|
||||
.map((status) => DropdownMenuItem<String>(
|
||||
value: status,
|
||||
child: Text(status, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_status = value;
|
||||
@@ -221,7 +242,6 @@ class _ParticipantFormScreenState extends State<ParticipantFormScreen> {
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 결제 여부
|
||||
SwitchListTile(
|
||||
title: const Text('결제 완료'),
|
||||
@@ -232,7 +252,6 @@ class _ParticipantFormScreenState extends State<ParticipantFormScreen> {
|
||||
});
|
||||
},
|
||||
),
|
||||
|
||||
// 결제 금액
|
||||
TextFormField(
|
||||
controller: _paidAmountController,
|
||||
@@ -252,10 +271,8 @@ class _ParticipantFormScreenState extends State<ParticipantFormScreen> {
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 메모 섹션
|
||||
_buildSectionTitle('추가 정보'),
|
||||
|
||||
// 메모
|
||||
TextFormField(
|
||||
controller: _notesController,
|
||||
@@ -266,7 +283,6 @@ class _ParticipantFormScreenState extends State<ParticipantFormScreen> {
|
||||
maxLines: 3,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// 저장 버튼
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
@@ -288,6 +304,92 @@ class _ParticipantFormScreenState extends State<ParticipantFormScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
// 게스트 사전입력 BottomSheet
|
||||
void _showGuestPrefillSheet() {
|
||||
final nameCtrl = TextEditingController(text: _nameController.text);
|
||||
final emailCtrl = TextEditingController(text: _emailController.text);
|
||||
final phoneCtrl = TextEditingController(text: _phoneNumberController.text);
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
builder: (ctx) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: 16,
|
||||
right: 16,
|
||||
bottom: MediaQuery.of(ctx).viewInsets.bottom + 16,
|
||||
top: 16,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('게스트 사전입력', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: nameCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '이름 *',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: emailCtrl,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '이메일',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: phoneCtrl,
|
||||
keyboardType: TextInputType.phone,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '전화번호',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
if (nameCtrl.text.trim().isEmpty) {
|
||||
ScaffoldMessenger.of(ctx).showSnackBar(
|
||||
const SnackBar(content: Text('이름은 필수 입력 항목입니다')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_nameController.text = nameCtrl.text.trim();
|
||||
_emailController.text = emailCtrl.text.trim();
|
||||
_phoneNumberController.text = phoneCtrl.text.trim();
|
||||
});
|
||||
Navigator.of(ctx).pop();
|
||||
},
|
||||
child: const Text('적용'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// 섹션 제목 위젯
|
||||
Widget _buildSectionTitle(String title) {
|
||||
return Column(
|
||||
|
||||
@@ -13,11 +13,11 @@ class ScoreFormScreen extends StatefulWidget {
|
||||
final List<Participant> participants;
|
||||
|
||||
const ScoreFormScreen({
|
||||
Key? key,
|
||||
super.key,
|
||||
required this.eventId,
|
||||
this.score,
|
||||
required this.participants,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
@override
|
||||
State<ScoreFormScreen> createState() => _ScoreFormScreenState();
|
||||
@@ -45,7 +45,7 @@ class _ScoreFormScreenState extends State<ScoreFormScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
|
||||
// 수정 모드인 경우 기존 데이터 로드
|
||||
if (widget.score != null) {
|
||||
_notesController.text = widget.score!.notes ?? '';
|
||||
@@ -53,18 +53,18 @@ class _ScoreFormScreenState extends State<ScoreFormScreen> {
|
||||
_scoreDate = widget.score!.date;
|
||||
_totalScoreController.text = widget.score!.totalScore.toString();
|
||||
_totalScore = widget.score!.totalScore;
|
||||
|
||||
|
||||
// 핸디캡 설정
|
||||
if (widget.score!.handicap != null) {
|
||||
_handicapController.text = widget.score!.handicap.toString();
|
||||
}
|
||||
|
||||
|
||||
// 프레임별 점수가 있는 경우 프레임별 입력 모드로 설정
|
||||
if (widget.score!.frames.isNotEmpty) {
|
||||
setState(() {
|
||||
_useFrameInput = true;
|
||||
});
|
||||
|
||||
|
||||
// 프레임별 점수 설정
|
||||
for (int i = 0; i < widget.score!.frames.length && i < 10; i++) {
|
||||
_frameControllers[i].text = widget.score!.frames[i].toString();
|
||||
@@ -96,7 +96,7 @@ class _ScoreFormScreenState extends State<ScoreFormScreen> {
|
||||
total += int.tryParse(controller.text) ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
setState(() {
|
||||
_totalScore = total;
|
||||
_totalScoreController.text = total.toString();
|
||||
@@ -113,27 +113,34 @@ class _ScoreFormScreenState extends State<ScoreFormScreen> {
|
||||
if (!_formKey.currentState!.validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
|
||||
try {
|
||||
final eventService = Provider.of<EventService>(context, listen: false);
|
||||
|
||||
|
||||
// 점수 데이터 준비
|
||||
final Map<String, dynamic> scoreData = {
|
||||
'eventId': widget.eventId,
|
||||
'memberId': _selectedParticipantId,
|
||||
'date': _scoreDate.toIso8601String(),
|
||||
'handicap': _handicapController.text.isEmpty ? null : int.parse(_handicapController.text),
|
||||
'notes': _notesController.text.trim().isEmpty ? null : _notesController.text.trim(),
|
||||
'handicap': _handicapController.text.isEmpty
|
||||
? null
|
||||
: int.parse(_handicapController.text),
|
||||
'notes': _notesController.text.trim().isEmpty
|
||||
? null
|
||||
: _notesController.text.trim(),
|
||||
};
|
||||
|
||||
|
||||
// 프레임별 입력 모드인 경우
|
||||
if (_useFrameInput) {
|
||||
final frames = _frameControllers
|
||||
.map((controller) => controller.text.isEmpty ? 0 : int.parse(controller.text))
|
||||
.map(
|
||||
(controller) =>
|
||||
controller.text.isEmpty ? 0 : int.parse(controller.text),
|
||||
)
|
||||
.toList();
|
||||
scoreData['frames'] = frames;
|
||||
scoreData['totalScore'] = _totalScore;
|
||||
@@ -142,25 +149,29 @@ class _ScoreFormScreenState extends State<ScoreFormScreen> {
|
||||
scoreData['totalScore'] = int.tryParse(_totalScoreController.text) ?? 0;
|
||||
scoreData['frames'] = []; // 빈 프레임 배열 전송
|
||||
}
|
||||
|
||||
|
||||
if (widget.score == null) {
|
||||
// 새 점수 추가
|
||||
await eventService.addScore(widget.eventId, scoreData);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('점수가 추가되었습니다')),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('점수가 추가되었습니다')));
|
||||
}
|
||||
} else {
|
||||
// 기존 점수 수정
|
||||
await eventService.updateScore(widget.eventId, widget.score!.id, scoreData);
|
||||
await eventService.updateScore(
|
||||
widget.eventId,
|
||||
widget.score!.id,
|
||||
scoreData,
|
||||
);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('점수가 수정되었습니다')),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('점수가 수정되었습니다')));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop(true); // 성공 결과와 함께 이전 화면으로 돌아가기
|
||||
}
|
||||
@@ -169,10 +180,10 @@ class _ScoreFormScreenState extends State<ScoreFormScreen> {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('점수 저장에 실패했습니다: $e')),
|
||||
);
|
||||
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('점수 저장에 실패했습니다: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -183,10 +194,7 @@ class _ScoreFormScreenState extends State<ScoreFormScreen> {
|
||||
appBar: AppBar(
|
||||
title: Text(widget.score == null ? '점수 추가' : '점수 수정'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.save),
|
||||
onPressed: _saveScore,
|
||||
),
|
||||
IconButton(icon: const Icon(Icons.save), onPressed: _saveScore),
|
||||
],
|
||||
),
|
||||
body: _isLoading
|
||||
@@ -200,17 +208,21 @@ class _ScoreFormScreenState extends State<ScoreFormScreen> {
|
||||
children: [
|
||||
// 기본 정보 섹션
|
||||
_buildSectionTitle('기본 정보'),
|
||||
|
||||
|
||||
// 참가자 선택
|
||||
DropdownButtonFormField<String>(
|
||||
key: const Key('score_form_participant_dropdown'),
|
||||
value: _selectedParticipantId,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '참가자 *',
|
||||
),
|
||||
decoration: const InputDecoration(labelText: '참가자 *'),
|
||||
isExpanded: true,
|
||||
items: widget.participants.map((participant) {
|
||||
return DropdownMenuItem(
|
||||
value: participant.memberId,
|
||||
child: Text(participant.name ?? '이름 없음'),
|
||||
child: Text(
|
||||
participant.name ?? '이름 없음',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (value) {
|
||||
@@ -226,7 +238,7 @@ class _ScoreFormScreenState extends State<ScoreFormScreen> {
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
|
||||
// 날짜 선택
|
||||
ListTile(
|
||||
title: const Text('점수 기록 날짜 *'),
|
||||
@@ -241,7 +253,7 @@ class _ScoreFormScreenState extends State<ScoreFormScreen> {
|
||||
firstDate: DateTime(2020),
|
||||
lastDate: DateTime(2030),
|
||||
);
|
||||
|
||||
|
||||
if (date != null && mounted) {
|
||||
setState(() {
|
||||
_scoreDate = date;
|
||||
@@ -250,10 +262,10 @@ class _ScoreFormScreenState extends State<ScoreFormScreen> {
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
|
||||
// 점수 입력 섹션
|
||||
_buildSectionTitle('점수 입력'),
|
||||
|
||||
|
||||
// 점수 입력 모드 전환 스위치
|
||||
SwitchListTile(
|
||||
title: const Text('프레임별 점수 입력'),
|
||||
@@ -274,88 +286,92 @@ class _ScoreFormScreenState extends State<ScoreFormScreen> {
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
|
||||
// 입력 모드에 따라 다른 UI 표시
|
||||
_useFrameInput ? Column(
|
||||
children: [
|
||||
// 프레임별 점수 입력
|
||||
GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 5,
|
||||
childAspectRatio: 1.5,
|
||||
crossAxisSpacing: 10,
|
||||
mainAxisSpacing: 10,
|
||||
),
|
||||
itemCount: 10,
|
||||
itemBuilder: (context, index) {
|
||||
return _buildFrameInput(index);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 계산된 총점 표시
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.shade50,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
_useFrameInput
|
||||
? Column(
|
||||
children: [
|
||||
const Text(
|
||||
'계산된 총점:',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
// 프레임별 점수 입력
|
||||
GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 5,
|
||||
childAspectRatio: 1.5,
|
||||
crossAxisSpacing: 10,
|
||||
mainAxisSpacing: 10,
|
||||
),
|
||||
itemCount: 10,
|
||||
itemBuilder: (context, index) {
|
||||
return _buildFrameInput(index);
|
||||
},
|
||||
),
|
||||
Text(
|
||||
_totalScore.toString(),
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.blue,
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 계산된 총점 표시
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.shade50,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment:
|
||||
MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'계산된 총점:',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_totalScore.toString(),
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.blue,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: TextFormField(
|
||||
// 총점 직접 입력
|
||||
controller: _totalScoreController,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '총점 *',
|
||||
hintText: '게임 총점을 입력하세요',
|
||||
prefixIcon: Icon(Icons.score),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '총점을 입력해주세요';
|
||||
}
|
||||
final score = int.tryParse(value);
|
||||
if (score == null) {
|
||||
return '숫자만 입력해주세요';
|
||||
}
|
||||
if (score < 0 || score > 300) {
|
||||
return '0-300 사이의 값을 입력해주세요';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_totalScore = int.tryParse(value) ?? 0;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
) : TextFormField(
|
||||
// 총점 직접 입력
|
||||
controller: _totalScoreController,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '총점 *',
|
||||
hintText: '게임 총점을 입력하세요',
|
||||
prefixIcon: Icon(Icons.score),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '총점을 입력해주세요';
|
||||
}
|
||||
final score = int.tryParse(value);
|
||||
if (score == null) {
|
||||
return '숫자만 입력해주세요';
|
||||
}
|
||||
if (score < 0 || score > 300) {
|
||||
return '0-300 사이의 값을 입력해주세요';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_totalScore = int.tryParse(value) ?? 0;
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
|
||||
// 핸디캡 입력
|
||||
TextFormField(
|
||||
controller: _handicapController,
|
||||
@@ -386,9 +402,7 @@ class _ScoreFormScreenState extends State<ScoreFormScreen> {
|
||||
children: [
|
||||
const Text(
|
||||
'핸디캡 포함 총점: ',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
Text(
|
||||
'${_totalScore + (int.tryParse(_handicapController.text) ?? 0)}',
|
||||
@@ -401,10 +415,10 @@ class _ScoreFormScreenState extends State<ScoreFormScreen> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
|
||||
// 메모 섹션
|
||||
_buildSectionTitle('추가 정보'),
|
||||
|
||||
|
||||
// 메모
|
||||
TextFormField(
|
||||
controller: _notesController,
|
||||
@@ -415,7 +429,7 @@ class _ScoreFormScreenState extends State<ScoreFormScreen> {
|
||||
maxLines: 3,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
|
||||
// 저장 버튼
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
@@ -482,10 +496,7 @@ class _ScoreFormScreenState extends State<ScoreFormScreen> {
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const Divider(),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
@@ -6,6 +6,7 @@ import '../../models/participant_model.dart';
|
||||
import '../../models/team_model.dart';
|
||||
import '../../services/event_service.dart';
|
||||
import '../../widgets/loading_indicator.dart';
|
||||
import '../../widgets/dialog_actions.dart';
|
||||
|
||||
class TeamGeneratorScreen extends StatefulWidget {
|
||||
final String eventId;
|
||||
@@ -40,9 +41,9 @@ class _TeamGeneratorScreenState extends State<TeamGeneratorScreen> {
|
||||
// 모든 참가자 기본 선택
|
||||
for (var participant in widget.participants) {
|
||||
if (participant.status == '참석함' || participant.status == '확인됨') {
|
||||
_selectedParticipants[participant.memberId ?? ''] = true;
|
||||
_selectedParticipants[participant.memberId] = true;
|
||||
} else {
|
||||
_selectedParticipants[participant.memberId ?? ''] = false;
|
||||
_selectedParticipants[participant.memberId] = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,7 +112,7 @@ class _TeamGeneratorScreenState extends State<TeamGeneratorScreen> {
|
||||
: '',
|
||||
eventId: widget.eventId,
|
||||
name: '${index + 1}',
|
||||
memberIds: teams[index].map((p) => p.memberId ?? '').toList(),
|
||||
memberIds: teams[index].map((p) => p.memberId).toList(),
|
||||
description: '자동 생성된 팀',
|
||||
),
|
||||
);
|
||||
@@ -145,17 +146,8 @@ class _TeamGeneratorScreenState extends State<TeamGeneratorScreen> {
|
||||
|
||||
try {
|
||||
final eventService = Provider.of<EventService>(context, listen: false);
|
||||
|
||||
// 팀 데이터 준비
|
||||
final teamsData = _generatedTeams.map((team) => {
|
||||
'eventId': widget.eventId,
|
||||
'name': team.name,
|
||||
'memberIds': team.memberIds,
|
||||
'description': team.description,
|
||||
}).toList();
|
||||
|
||||
// 팀 저장
|
||||
await eventService.saveTeams(widget.eventId, teamsData);
|
||||
// 팀 저장 (서비스는 List<Team>을 기대함)
|
||||
await eventService.saveTeams(widget.eventId, _generatedTeams);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -301,7 +293,7 @@ class _TeamGeneratorScreenState extends State<TeamGeneratorScreen> {
|
||||
value: _selectedParticipants[participant.memberId] ?? false,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_selectedParticipants[participant.memberId ?? ''] = value ?? false;
|
||||
_selectedParticipants[participant.memberId] = value ?? false;
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -360,27 +352,22 @@ class _TeamGeneratorScreenState extends State<TeamGeneratorScreen> {
|
||||
},
|
||||
controller: TextEditingController(text: team.name),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_generatedTeams[teamIndex] = Team(
|
||||
id: team.id,
|
||||
eventId: team.eventId,
|
||||
name: newName,
|
||||
memberIds: team.memberIds,
|
||||
description: team.description,
|
||||
);
|
||||
});
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('저장'),
|
||||
),
|
||||
],
|
||||
actions: DialogActions.confirm(
|
||||
onCancel: () => Navigator.of(context).pop(),
|
||||
onConfirm: () {
|
||||
setState(() {
|
||||
_generatedTeams[teamIndex] = Team(
|
||||
id: team.id,
|
||||
eventId: team.eventId,
|
||||
name: newName,
|
||||
memberIds: team.memberIds,
|
||||
description: team.description,
|
||||
);
|
||||
});
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
confirmText: '저장',
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -416,45 +403,38 @@ class _TeamGeneratorScreenState extends State<TeamGeneratorScreen> {
|
||||
}
|
||||
},
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
if (targetTeamIndex != teamIndex) {
|
||||
setState(() {
|
||||
// 현재 팀에서 제거
|
||||
_generatedTeams[teamIndex] = Team(
|
||||
id: team.id,
|
||||
eventId: team.eventId,
|
||||
name: team.name,
|
||||
memberIds: team.memberIds
|
||||
.where((id) => id != member.memberId)
|
||||
.toList(),
|
||||
description: team.description,
|
||||
);
|
||||
|
||||
// 대상 팀에 추가
|
||||
final targetTeam = _generatedTeams[targetTeamIndex];
|
||||
_generatedTeams[targetTeamIndex] = Team(
|
||||
id: targetTeam.id,
|
||||
eventId: targetTeam.eventId,
|
||||
name: targetTeam.name,
|
||||
memberIds: [
|
||||
...targetTeam.memberIds,
|
||||
member.memberId ?? '',
|
||||
],
|
||||
description: targetTeam.description,
|
||||
);
|
||||
});
|
||||
}
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('이동'),
|
||||
),
|
||||
],
|
||||
actions: DialogActions.confirm(
|
||||
onCancel: () => Navigator.of(context).pop(),
|
||||
onConfirm: () {
|
||||
if (targetTeamIndex != teamIndex) {
|
||||
setState(() {
|
||||
_generatedTeams[teamIndex] = Team(
|
||||
id: team.id,
|
||||
eventId: team.eventId,
|
||||
name: team.name,
|
||||
memberIds: team.memberIds
|
||||
.where((id) => id != member.memberId)
|
||||
.toList(),
|
||||
description: team.description,
|
||||
);
|
||||
|
||||
final targetTeam = _generatedTeams[targetTeamIndex];
|
||||
_generatedTeams[targetTeamIndex] = Team(
|
||||
id: targetTeam.id,
|
||||
eventId: targetTeam.eventId,
|
||||
name: targetTeam.name,
|
||||
memberIds: [
|
||||
...targetTeam.memberIds,
|
||||
member.memberId,
|
||||
],
|
||||
description: targetTeam.description,
|
||||
);
|
||||
});
|
||||
}
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
confirmText: '이동',
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -40,7 +40,8 @@ class _ClubStatisticsScreenState extends State<ClubStatisticsScreen> {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
// await 이전에 messenger 캡처하여 안전하게 사용
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
try {
|
||||
final authService = Provider.of<AuthService>(context, listen: false);
|
||||
final clubService = Provider.of<ClubService>(context, listen: false);
|
||||
@@ -73,9 +74,9 @@ class _ClubStatisticsScreenState extends State<ClubStatisticsScreen> {
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('데이터 로드 중 오류가 발생했습니다: $e')));
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('데이터 로드 중 오류가 발생했습니다: $e')),
|
||||
);
|
||||
} finally {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
|
||||
@@ -10,6 +10,7 @@ import '../../services/score_service.dart';
|
||||
import '../../models/member_model.dart';
|
||||
import '../../models/score_model.dart';
|
||||
import 'score_entry_screen.dart';
|
||||
import '../../widgets/dialog_actions.dart';
|
||||
|
||||
class MemberStatisticsScreen extends StatefulWidget {
|
||||
final String memberId;
|
||||
@@ -56,12 +57,15 @@ class _MemberStatisticsScreenState extends State<MemberStatisticsScreen>
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
// await 이전에 messenger 캡처
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
|
||||
try {
|
||||
final authService = Provider.of<AuthService>(context, listen: false);
|
||||
final clubService = Provider.of<ClubService>(context, listen: false);
|
||||
final memberService = Provider.of<MemberService>(context, listen: false);
|
||||
final scoreService = Provider.of<ScoreService>(context, listen: false);
|
||||
|
||||
final clubService = Provider.of<ClubService>(context, listen: false);
|
||||
|
||||
if (clubService.currentClub != null) {
|
||||
// 회원 정보 로드
|
||||
_member = await memberService.fetchMemberById(widget.memberId);
|
||||
@@ -80,13 +84,15 @@ class _MemberStatisticsScreenState extends State<MemberStatisticsScreen>
|
||||
]);
|
||||
}
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('데이터 로드 중 오류가 발생했습니다: $e')));
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('데이터 로드 중 오류가 발생했습니다: $e')),
|
||||
);
|
||||
} finally {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -608,7 +614,7 @@ class _MemberStatisticsScreenState extends State<MemberStatisticsScreen>
|
||||
dotData: const FlDotData(show: true),
|
||||
belowBarData: BarAreaData(
|
||||
show: true,
|
||||
color: Colors.blue.withOpacity(0.2),
|
||||
color: Colors.blue.withValues(alpha: 0.2),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -619,43 +625,28 @@ class _MemberStatisticsScreenState extends State<MemberStatisticsScreen>
|
||||
void _showDeleteConfirmationDialog(Score score) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('점수 삭제'),
|
||||
content: const Text('이 점수 기록을 정말 삭제하시겠습니까?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
Navigator.of(context).pop();
|
||||
|
||||
try {
|
||||
final scoreService = Provider.of<ScoreService>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
await scoreService.deleteScore(score.id);
|
||||
|
||||
// 데이터 새로고침
|
||||
_loadMemberData();
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('점수가 삭제되었습니다')));
|
||||
}
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('점수 삭제에 실패했습니다: $e')));
|
||||
}
|
||||
},
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
child: const Text('삭제'),
|
||||
),
|
||||
],
|
||||
actions: DialogActions.confirm(
|
||||
onCancel: () => Navigator.of(dialogContext).pop(),
|
||||
onConfirm: () async {
|
||||
// await 전에 필요한 객체 캡처
|
||||
final navigator = Navigator.of(dialogContext);
|
||||
final messenger = ScaffoldMessenger.of(dialogContext);
|
||||
final scoreService = Provider.of<ScoreService>(dialogContext, listen: false);
|
||||
navigator.pop();
|
||||
try {
|
||||
await scoreService.deleteScore(score.id);
|
||||
_loadMemberData();
|
||||
messenger.showSnackBar(const SnackBar(content: Text('점수가 삭제되었습니다')));
|
||||
} catch (e) {
|
||||
messenger.showSnackBar(SnackBar(content: Text('점수 삭제에 실패했습니다: $e')));
|
||||
}
|
||||
},
|
||||
destructive: true,
|
||||
confirmText: '삭제',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -65,21 +65,16 @@ class _ScoreEntryScreenState extends State<ScoreEntryScreen> {
|
||||
|
||||
// 점수 저장
|
||||
Future<void> _saveScore() async {
|
||||
if (!_formKey.currentState!.validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_selectedMemberId == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('회원을 선택해주세요')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
|
||||
// await 전에 필요한 파생 객체 캡처 (catch에서도 사용 가능하도록 try 밖)
|
||||
final navigator = Navigator.of(context);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
|
||||
try {
|
||||
final scoreService = Provider.of<ScoreService>(context, listen: false);
|
||||
final clubService = Provider.of<ClubService>(context, listen: false);
|
||||
@@ -106,22 +101,23 @@ class _ScoreEntryScreenState extends State<ScoreEntryScreen> {
|
||||
|
||||
// 점수 저장
|
||||
await scoreService.addScore(scoreData);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('점수가 저장되었습니다')),
|
||||
);
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
// 성공 알림 및 화면 닫기
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(content: Text('점수가 저장되었습니다')),
|
||||
);
|
||||
navigator.pop();
|
||||
}
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
// 에러 알림 (사전 캡처한 messenger 사용)
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('점수 저장에 실패했습니다: $e')),
|
||||
);
|
||||
} finally {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,15 +193,21 @@ class _ScoreEntryScreenState extends State<ScoreEntryScreen> {
|
||||
final members = memberService.members;
|
||||
|
||||
return DropdownButtonFormField<String>(
|
||||
key: const Key('score_entry_member_dropdown'),
|
||||
decoration: const InputDecoration(
|
||||
labelText: '회원 선택',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
value: _selectedMemberId,
|
||||
isExpanded: true,
|
||||
items: members.map((member) {
|
||||
return DropdownMenuItem<String>(
|
||||
value: member.id,
|
||||
child: Text(member.name),
|
||||
child: Text(
|
||||
member.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (value) {
|
||||
@@ -230,11 +232,13 @@ class _ScoreEntryScreenState extends State<ScoreEntryScreen> {
|
||||
final events = eventService.events;
|
||||
|
||||
return DropdownButtonFormField<String>(
|
||||
key: const Key('score_entry_event_dropdown'),
|
||||
decoration: const InputDecoration(
|
||||
labelText: '이벤트 선택 (선택사항)',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
value: _selectedEventId,
|
||||
isExpanded: true,
|
||||
items: [
|
||||
const DropdownMenuItem<String>(
|
||||
value: null,
|
||||
@@ -243,7 +247,11 @@ class _ScoreEntryScreenState extends State<ScoreEntryScreen> {
|
||||
...events.map((event) {
|
||||
return DropdownMenuItem<String>(
|
||||
value: event.id,
|
||||
child: Text(event.title),
|
||||
child: Text(
|
||||
event.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -136,7 +136,7 @@ class _SubscriptionDetailsScreenState extends State<SubscriptionDetailsScreen> {
|
||||
color: Colors.white,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
color: Colors.black.withValues(alpha: 0.1),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, -5),
|
||||
),
|
||||
@@ -207,7 +207,7 @@ class _SubscriptionDetailsScreenState extends State<SubscriptionDetailsScreen> {
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.amber.withOpacity(0.1),
|
||||
color: Colors.amber.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.amber),
|
||||
),
|
||||
@@ -349,6 +349,8 @@ class _SubscriptionDetailsScreenState extends State<SubscriptionDetailsScreen> {
|
||||
}
|
||||
|
||||
Future<void> _subscribe(BuildContext context) async {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final navigator = Navigator.of(context);
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
@@ -361,12 +363,12 @@ class _SubscriptionDetailsScreenState extends State<SubscriptionDetailsScreen> {
|
||||
);
|
||||
|
||||
if (success && mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(content: Text('구독이 성공적으로 완료되었습니다')),
|
||||
);
|
||||
Navigator.of(context).pop(); // 상세 화면 닫기
|
||||
navigator.pop(); // 상세 화면 닫기
|
||||
} else if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(subscriptionService.error ?? '구독 처리 중 오류가 발생했습니다'),
|
||||
backgroundColor: Colors.red,
|
||||
@@ -375,7 +377,7 @@ class _SubscriptionDetailsScreenState extends State<SubscriptionDetailsScreen> {
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('오류: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
|
||||
import '../../models/subscription_model.dart';
|
||||
import '../../services/subscription_service.dart';
|
||||
import 'subscription_details_screen.dart';
|
||||
import '../../widgets/dialog_actions.dart';
|
||||
|
||||
class SubscriptionScreen extends StatefulWidget {
|
||||
const SubscriptionScreen({super.key});
|
||||
@@ -24,7 +25,7 @@ class _SubscriptionScreenState extends State<SubscriptionScreen> {
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
subscriptionService.fetchCurrentSubscription();
|
||||
subscriptionService.loadCurrentSubscription();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -49,7 +50,7 @@ class _SubscriptionScreenState extends State<SubscriptionScreen> {
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: () =>
|
||||
subscriptionService.fetchCurrentSubscription(),
|
||||
subscriptionService.loadCurrentSubscription(),
|
||||
child: const Text('다시 시도'),
|
||||
),
|
||||
],
|
||||
@@ -121,8 +122,8 @@ class _SubscriptionScreenState extends State<SubscriptionScreen> {
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: subscription.isActive
|
||||
? Colors.green.withOpacity(0.1)
|
||||
: Colors.grey.withOpacity(0.1),
|
||||
? Colors.green.withValues(alpha: 0.1)
|
||||
: Colors.grey.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: subscription.isActive
|
||||
@@ -222,7 +223,7 @@ class _SubscriptionScreenState extends State<SubscriptionScreen> {
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
// 플랜 선택 화면으로 전환
|
||||
subscriptionService.fetchCurrentSubscription();
|
||||
subscriptionService.loadCurrentSubscription();
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.upgrade),
|
||||
@@ -271,7 +272,7 @@ class _SubscriptionScreenState extends State<SubscriptionScreen> {
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green.withOpacity(0.1),
|
||||
color: Colors.green.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.green),
|
||||
),
|
||||
@@ -340,7 +341,7 @@ class _SubscriptionScreenState extends State<SubscriptionScreen> {
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.amber.withOpacity(0.1),
|
||||
color: Colors.amber.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: Colors.amber),
|
||||
),
|
||||
@@ -441,42 +442,40 @@ class _SubscriptionScreenState extends State<SubscriptionScreen> {
|
||||
}
|
||||
|
||||
void _showCancelDialog(SubscriptionService subscriptionService) {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final navigator = Navigator.of(context);
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('구독 취소'),
|
||||
content: const Text(
|
||||
'정말로 구독을 취소하시겠습니까?\n'
|
||||
'취소하면 현재 구독 기간이 끝날 때까지만 서비스를 이용할 수 있습니다.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('아니오'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
Navigator.of(context).pop();
|
||||
final success = await subscriptionService.cancelSubscription();
|
||||
if (success && mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('구독이 취소되었습니다')));
|
||||
}
|
||||
},
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
child: const Text('예, 취소합니다'),
|
||||
),
|
||||
],
|
||||
actions: DialogActions.confirm(
|
||||
onCancel: () => Navigator.of(dialogContext).pop(),
|
||||
onConfirm: () async {
|
||||
navigator.pop();
|
||||
final success = await subscriptionService.cancelSubscription();
|
||||
if (success && mounted) {
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(content: Text('구독이 취소되었습니다')),
|
||||
);
|
||||
}
|
||||
},
|
||||
destructive: true,
|
||||
confirmText: '예, 취소합니다',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _toggleAutoRenew(SubscriptionService subscriptionService) async {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final success = await subscriptionService.toggleAutoRenew();
|
||||
if (success && mounted) {
|
||||
final isAutoRenew = subscriptionService.currentSubscription!.autoRenew;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(isAutoRenew ? '자동 갱신이 활성화되었습니다' : '자동 갱신이 비활성화되었습니다'),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user