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

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