회원목록

This commit is contained in:
2025-08-01 16:45:26 +09:00
parent a996def67a
commit ed8c7eacba
191 changed files with 17491 additions and 0 deletions
@@ -0,0 +1,456 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../services/admin_service.dart';
import '../../services/subscription_service.dart';
import '../../models/subscription_model.dart';
/// 관리자 대시보드 화면
class AdminDashboardScreen extends StatefulWidget {
const AdminDashboardScreen({super.key});
@override
State<AdminDashboardScreen> createState() => _AdminDashboardScreenState();
}
class _AdminDashboardScreenState extends State<AdminDashboardScreen> {
Map<String, dynamic>? _analyticsData;
bool _isLoading = false;
@override
void initState() {
super.initState();
_loadData();
}
/// 데이터 로드
Future<void> _loadData() async {
setState(() {
_isLoading = true;
});
try {
final adminService = Provider.of<AdminService>(context, listen: false);
final analyticsData = await adminService.fetchClubAnalytics();
setState(() {
_analyticsData = analyticsData;
});
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('데이터 로드 중 오류가 발생했습니다: $e')),
);
} finally {
setState(() {
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
final adminService = Provider.of<AdminService>(context);
final subscriptionService = Provider.of<SubscriptionService>(context);
return Scaffold(
appBar: AppBar(
title: const Text('관리자 대시보드'),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _loadData,
),
],
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: RefreshIndicator(
onRefresh: _loadData,
child: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildPermissionsCard(adminService),
const SizedBox(height: 16),
_buildSubscriptionCard(subscriptionService),
const SizedBox(height: 16),
_buildAnalyticsCard(),
const SizedBox(height: 16),
_buildQuickActionsCard(context, adminService),
],
),
),
),
);
}
/// 권한 정보 카드
Widget _buildPermissionsCard(AdminService adminService) {
return Card(
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.admin_panel_settings),
const SizedBox(width: 8),
Text(
'관리자 권한',
style: Theme.of(context).textTheme.titleLarge,
),
],
),
const Divider(),
const SizedBox(height: 8),
_buildPermissionItem(
'회원 관리',
adminService.canManageMembers,
Icons.people,
),
_buildPermissionItem(
'이벤트 관리',
adminService.canManageEvents,
Icons.event,
),
_buildPermissionItem(
'점수 관리',
adminService.canManageScores,
Icons.score,
),
_buildPermissionItem(
'설정 관리',
adminService.canManageSettings,
Icons.settings,
),
_buildPermissionItem(
'구독 관리',
adminService.canManageSubscription,
Icons.subscriptions,
),
_buildPermissionItem(
'역할 관리',
adminService.canManageRoles,
Icons.admin_panel_settings,
),
_buildPermissionItem(
'재무 정보 조회',
adminService.canViewFinancials,
Icons.attach_money,
),
_buildPermissionItem(
'데이터 내보내기',
adminService.canExportData,
Icons.file_download,
),
],
),
),
);
}
/// 권한 항목 위젯
Widget _buildPermissionItem(String title, bool hasPermission, IconData icon) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Row(
children: [
Icon(
icon,
size: 18,
color: hasPermission ? Colors.green : Colors.grey,
),
const SizedBox(width: 8),
Text(title),
const Spacer(),
Icon(
hasPermission ? Icons.check_circle : Icons.cancel,
color: hasPermission ? Colors.green : Colors.red,
size: 18,
),
],
),
);
}
/// 구독 정보 카드
Widget _buildSubscriptionCard(SubscriptionService subscriptionService) {
final subscription = subscriptionService.currentSubscription;
return Card(
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.subscriptions),
const SizedBox(width: 8),
Text(
'구독 정보',
style: Theme.of(context).textTheme.titleLarge,
),
],
),
const Divider(),
const SizedBox(height: 8),
if (subscription != null) ...[
_buildInfoRow('플랜', subscription.planName),
_buildInfoRow('상태', _getStatusText(subscription.status)),
_buildInfoRow('시작일', _formatDate(subscription.startDate)),
_buildInfoRow('만료일', _formatDate(subscription.endDate)),
_buildInfoRow('자동 갱신', subscription.autoRenew ? '활성화' : '비활성화'),
_buildInfoRow('남은 일수', '${subscription.daysLeft}'),
] else
const Text('활성화된 구독이 없습니다.'),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
Navigator.of(context).pushNamed('/subscription/manage');
},
child: const Text('구독 관리'),
),
],
),
),
);
}
/// 분석 데이터 카드
Widget _buildAnalyticsCard() {
return Card(
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.analytics),
const SizedBox(width: 8),
Text(
'클럽 통계',
style: Theme.of(context).textTheme.titleLarge,
),
],
),
const Divider(),
const SizedBox(height: 8),
if (_analyticsData != null) ...[
_buildInfoRow('총 회원 수', '${_analyticsData!['totalMembers'] ?? 0}'),
_buildInfoRow('활성 회원 수', '${_analyticsData!['activeMembers'] ?? 0}'),
_buildInfoRow('이번 달 신규 회원', '${_analyticsData!['newMembersThisMonth'] ?? 0}'),
_buildInfoRow('총 이벤트 수', '${_analyticsData!['totalEvents'] ?? 0}'),
_buildInfoRow('이번 달 이벤트', '${_analyticsData!['eventsThisMonth'] ?? 0}'),
_buildInfoRow('총 게임 수', '${_analyticsData!['totalGames'] ?? 0}게임'),
_buildInfoRow('평균 점수', '${_analyticsData!['averageScore'] ?? 0}'),
] else
const Text('통계 데이터를 불러올 수 없습니다.'),
],
),
),
);
}
/// 빠른 작업 카드
Widget _buildQuickActionsCard(BuildContext context, AdminService adminService) {
return Card(
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.flash_on),
const SizedBox(width: 8),
Text(
'빠른 작업',
style: Theme.of(context).textTheme.titleLarge,
),
],
),
const Divider(),
const SizedBox(height: 8),
Wrap(
spacing: 8.0,
runSpacing: 8.0,
children: [
if (adminService.canManageMembers)
_buildActionButton(
context,
'회원 관리',
Icons.people,
() => Navigator.of(context).pushNamed('/admin/members'),
),
if (adminService.canManageEvents)
_buildActionButton(
context,
'이벤트 관리',
Icons.event,
() => Navigator.of(context).pushNamed('/admin/events'),
),
if (adminService.canManageSettings)
_buildActionButton(
context,
'클럽 설정',
Icons.settings,
() => Navigator.of(context).pushNamed('/admin/settings'),
),
if (adminService.canManageRoles)
_buildActionButton(
context,
'역할 관리',
Icons.admin_panel_settings,
() => Navigator.of(context).pushNamed('/admin/roles'),
),
if (adminService.canExportData)
_buildActionButton(
context,
'데이터 내보내기',
Icons.file_download,
() => _showExportDialog(context),
),
],
),
],
),
),
);
}
/// 작업 버튼 위젯
Widget _buildActionButton(
BuildContext context,
String label,
IconData icon,
VoidCallback onPressed,
) {
return ElevatedButton.icon(
onPressed: onPressed,
icon: Icon(icon, size: 18),
label: Text(label),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
);
}
/// 데이터 내보내기 다이얼로그
void _showExportDialog(BuildContext context) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('데이터 내보내기'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.people),
title: const Text('회원 데이터'),
onTap: () => _exportData(context, 'members'),
),
ListTile(
leading: const Icon(Icons.event),
title: const Text('이벤트 데이터'),
onTap: () => _exportData(context, 'events'),
),
ListTile(
leading: const Icon(Icons.score),
title: const Text('점수 데이터'),
onTap: () => _exportData(context, 'scores'),
),
ListTile(
leading: const Icon(Icons.analytics),
title: const Text('통계 데이터'),
onTap: () => _exportData(context, 'analytics'),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('취소'),
),
],
),
);
}
/// 데이터 내보내기 처리
Future<void> _exportData(BuildContext context, String dataType) async {
Navigator.of(context).pop(); // 다이얼로그 닫기
setState(() {
_isLoading = true;
});
try {
final adminService = Provider.of<AdminService>(context, listen: false);
final downloadUrl = await adminService.exportClubData(dataType);
if (downloadUrl != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('데이터 내보내기 성공: $downloadUrl')),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('데이터 내보내기에 실패했습니다.')),
);
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('데이터 내보내기 중 오류가 발생했습니다: $e')),
);
} finally {
setState(() {
_isLoading = false;
});
}
}
/// 정보 행 위젯
Widget _buildInfoRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'$label:',
style: const TextStyle(fontWeight: FontWeight.bold),
),
Text(value),
],
),
);
}
/// 날짜 포맷팅
String _formatDate(DateTime date) {
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
}
/// 구독 상태 텍스트
String _getStatusText(SubscriptionStatus status) {
switch (status) {
case SubscriptionStatus.active:
return '활성화';
case SubscriptionStatus.canceled:
return '취소됨';
case SubscriptionStatus.expired:
return '만료됨';
case SubscriptionStatus.pending:
return '대기 중';
case SubscriptionStatus.failed:
return '실패';
}
}
}
@@ -0,0 +1,168 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../services/auth_service.dart';
class ForgotPasswordScreen extends StatefulWidget {
const ForgotPasswordScreen({super.key});
@override
State<ForgotPasswordScreen> createState() => _ForgotPasswordScreenState();
}
class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
bool _isSubmitting = false;
bool _emailSent = false;
@override
void dispose() {
_emailController.dispose();
super.dispose();
}
// 비밀번호 재설정 이메일 전송 함수
Future<void> _resetPassword() async {
if (_formKey.currentState!.validate()) {
setState(() {
_isSubmitting = true;
});
final authService = Provider.of<AuthService>(context, listen: false);
final success = await authService.sendPasswordResetEmail(
_emailController.text.trim(),
);
setState(() {
_isSubmitting = false;
_emailSent = success;
});
if (success && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('비밀번호 재설정 이메일이 전송되었습니다. 이메일을 확인해주세요.'),
backgroundColor: Colors.green,
),
);
} else if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('비밀번호 재설정 이메일 전송에 실패했습니다. 이메일 주소를 확인해주세요.'),
backgroundColor: Colors.red,
),
);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('비밀번호 찾기'),
),
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Form(
key: _formKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Icon(
Icons.lock_reset,
size: 80,
color: Colors.blue,
),
const SizedBox(height: 24),
const Text(
'비밀번호를 잊으셨나요?',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
const Text(
'가입하신 이메일 주소를 입력하시면 비밀번호 재설정 링크를 보내드립니다.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16,
color: Colors.grey,
),
),
const SizedBox(height: 32),
// 이메일 입력 필드
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: '이메일',
prefixIcon: Icon(Icons.email),
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.isEmpty) {
return '이메일을 입력해주세요';
}
if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(value)) {
return '유효한 이메일 주소를 입력해주세요';
}
return null;
},
),
const SizedBox(height: 24),
// 비밀번호 재설정 이메일 전송 버튼
ElevatedButton(
onPressed: _isSubmitting ? null : _resetPassword,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
),
child: _isSubmitting
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text(
'비밀번호 재설정 이메일 전송',
style: TextStyle(fontSize: 16),
),
),
const SizedBox(height: 16),
// 로그인 화면으로 돌아가기
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('비밀번호가 기억나셨나요?'),
TextButton(
onPressed: () {
Navigator.pop(context); // 로그인 화면으로 돌아가기
},
child: const Text('로그인'),
),
],
),
],
),
),
),
),
),
);
}
}
+212
View File
@@ -0,0 +1,212 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../services/auth_service.dart';
import 'register_screen.dart';
import 'forgot_password_screen.dart';
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final _formKey = GlobalKey<FormState>();
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
bool _isPasswordVisible = false;
bool _rememberMe = false;
@override
void dispose() {
_usernameController.dispose();
_passwordController.dispose();
super.dispose();
}
// 로그인 처리 함수
Future<void> _login() async {
if (_formKey.currentState!.validate()) {
final authService = Provider.of<AuthService>(context, listen: false);
final success = await authService.login(
_usernameController.text.trim(),
_passwordController.text,
);
if (success && mounted) {
// 로그인 성공 시 홈 화면으로 이동
Navigator.of(context).pushNamedAndRemoveUntil('/', (route) => false);
} else if (!success && mounted) {
// 로그인 실패 시 스낵바 표시
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('로그인에 실패했습니다. 아이디와 비밀번호를 확인해주세요.'),
backgroundColor: Colors.red,
),
);
}
}
}
@override
Widget build(BuildContext context) {
final authService = Provider.of<AuthService>(context);
return Scaffold(
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Form(
key: _formKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 로고 이미지
Column(
children: [
Image.asset(
'assets/images/logo.png',
width: 120,
height: 120,
),
const SizedBox(height: 16),
const Text(
'레인보우 - 볼링 클럽 매니저',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.blue,
),
),
],
),
const SizedBox(height: 48),
// 사용자명(아이디) 입력 필드
TextFormField(
controller: _usernameController,
decoration: const InputDecoration(
labelText: '아이디',
prefixIcon: Icon(Icons.person),
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.isEmpty) {
return '아이디를 입력해주세요';
}
return null;
},
),
const SizedBox(height: 16),
// 비밀번호 입력 필드
TextFormField(
controller: _passwordController,
obscureText: !_isPasswordVisible,
decoration: InputDecoration(
labelText: '비밀번호',
prefixIcon: const Icon(Icons.lock),
border: const OutlineInputBorder(),
suffixIcon: IconButton(
icon: Icon(
_isPasswordVisible ? Icons.visibility : Icons.visibility_off,
),
onPressed: () {
setState(() {
_isPasswordVisible = !_isPasswordVisible;
});
},
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return '비밀번호를 입력해주세요';
}
return null;
},
),
const SizedBox(height: 8),
// 로그인 상태 유지 체크박스
Row(
children: [
Checkbox(
value: _rememberMe,
onChanged: (value) {
setState(() {
_rememberMe = value ?? false;
});
},
),
const Text('로그인 상태 유지'),
const Spacer(),
TextButton(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const ForgotPasswordScreen(),
),
);
},
child: const Text('비밀번호 찾기'),
),
],
),
const SizedBox(height: 24),
// 로그인 버튼
ElevatedButton(
onPressed: authService.isLoading ? null : _login,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
),
child: authService.isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text(
'로그인',
style: TextStyle(fontSize: 16),
),
),
const SizedBox(height: 16),
// 회원가입 링크
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('계정이 없으신가요?'),
TextButton(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const RegisterScreen(),
),
);
},
child: const Text('회원가입'),
),
],
),
],
),
),
),
),
),
);
}
}
+186
View File
@@ -0,0 +1,186 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../services/auth_service.dart';
import '../../models/user_model.dart';
class ProfileScreen extends StatefulWidget {
const ProfileScreen({super.key});
@override
State<ProfileScreen> createState() => _ProfileScreenState();
}
class _ProfileScreenState extends State<ProfileScreen> {
final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController();
bool _isEditing = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
final user = Provider.of<AuthService>(context, listen: false).currentUser;
if (user != null) {
_nameController.text = user.name;
}
});
}
@override
void dispose() {
_nameController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('내 프로필'),
actions: [
IconButton(
icon: Icon(_isEditing ? Icons.save : Icons.edit),
onPressed: () {
setState(() {
_isEditing = !_isEditing;
});
if (!_isEditing) {
// 저장 로직 구현 (추후 개발)
if (_formKey.currentState!.validate()) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('프로필이 업데이트되었습니다')),
);
}
}
},
),
],
),
body: Consumer<AuthService>(
builder: (context, authService, child) {
final User? user = authService.currentUser;
if (user == null) {
return const Center(child: Text('사용자 정보를 불러올 수 없습니다'));
}
return SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// 프로필 이미지
CircleAvatar(
radius: 50,
backgroundImage: user.profileImage != null
? NetworkImage(user.profileImage!)
: null,
child: user.profileImage == null
? Text(
user.name.isNotEmpty ? user.name[0].toUpperCase() : '?',
style: const TextStyle(fontSize: 40),
)
: null,
),
const SizedBox(height: 16),
// 이름 필드
TextFormField(
controller: _nameController,
enabled: _isEditing,
decoration: const InputDecoration(
labelText: '이름',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.isEmpty) {
return '이름을 입력해주세요';
}
return null;
},
),
const SizedBox(height: 16),
// 이메일 (수정 불가)
TextFormField(
initialValue: user.email,
enabled: false,
decoration: const InputDecoration(
labelText: '이메일',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
// 역할 표시
TextFormField(
initialValue: _getRoleDisplayName(user.role),
enabled: false,
decoration: const InputDecoration(
labelText: '역할',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
// 클럽 역할 표시 (클럽이 있는 경우)
if (user.clubRole != null)
TextFormField(
initialValue: _getRoleDisplayName(user.clubRole!),
enabled: false,
decoration: const InputDecoration(
labelText: '클럽 내 역할',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 32),
// 로그아웃 버튼
ElevatedButton(
onPressed: () async {
await authService.logout();
if (context.mounted) {
Navigator.of(context).pushReplacementNamed('/login');
}
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: 32,
vertical: 12,
),
),
child: const Text('로그아웃'),
),
],
),
),
);
},
),
);
}
// 역할 표시 이름 변환
String _getRoleDisplayName(String role) {
switch (role) {
case 'admin':
return '관리자';
case 'superadmin':
return '최고 관리자';
case 'user':
return '일반 사용자';
case 'owner':
return '클럽 소유자';
case 'manager':
return '클럽 매니저';
case 'member':
return '클럽 회원';
default:
return role;
}
}
}
@@ -0,0 +1,222 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../services/auth_service.dart';
class RegisterScreen extends StatefulWidget {
const RegisterScreen({super.key});
@override
State<RegisterScreen> createState() => _RegisterScreenState();
}
class _RegisterScreenState extends State<RegisterScreen> {
final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
final _confirmPasswordController = TextEditingController();
bool _isPasswordVisible = false;
bool _isConfirmPasswordVisible = false;
@override
void dispose() {
_nameController.dispose();
_emailController.dispose();
_passwordController.dispose();
_confirmPasswordController.dispose();
super.dispose();
}
// 회원가입 처리 함수
Future<void> _register() async {
if (_formKey.currentState!.validate()) {
final authService = Provider.of<AuthService>(context, listen: false);
final success = await authService.register(
_nameController.text.trim(),
_emailController.text.trim(),
_passwordController.text,
);
if (success && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('회원가입이 완료되었습니다. 로그인해주세요.'),
backgroundColor: Colors.green,
),
);
Navigator.pop(context); // 로그인 화면으로 돌아가기
} else if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('회원가입에 실패했습니다. 다시 시도해주세요.'),
backgroundColor: Colors.red,
),
);
}
}
}
@override
Widget build(BuildContext context) {
final authService = Provider.of<AuthService>(context);
return Scaffold(
appBar: AppBar(
title: const Text('회원가입'),
),
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Form(
key: _formKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 이름 입력 필드
TextFormField(
controller: _nameController,
decoration: const InputDecoration(
labelText: '이름',
prefixIcon: Icon(Icons.person),
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.isEmpty) {
return '이름을 입력해주세요';
}
return null;
},
),
const SizedBox(height: 16),
// 이메일 입력 필드
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: '이메일',
prefixIcon: Icon(Icons.email),
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.isEmpty) {
return '이메일을 입력해주세요';
}
if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(value)) {
return '유효한 이메일 주소를 입력해주세요';
}
return null;
},
),
const SizedBox(height: 16),
// 비밀번호 입력 필드
TextFormField(
controller: _passwordController,
obscureText: !_isPasswordVisible,
decoration: InputDecoration(
labelText: '비밀번호',
prefixIcon: const Icon(Icons.lock),
border: const OutlineInputBorder(),
suffixIcon: IconButton(
icon: Icon(
_isPasswordVisible ? Icons.visibility : Icons.visibility_off,
),
onPressed: () {
setState(() {
_isPasswordVisible = !_isPasswordVisible;
});
},
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return '비밀번호를 입력해주세요';
}
if (value.length < 6) {
return '비밀번호는 6자 이상이어야 합니다';
}
return null;
},
),
const SizedBox(height: 16),
// 비밀번호 확인 입력 필드
TextFormField(
controller: _confirmPasswordController,
obscureText: !_isConfirmPasswordVisible,
decoration: InputDecoration(
labelText: '비밀번호 확인',
prefixIcon: const Icon(Icons.lock_outline),
border: const OutlineInputBorder(),
suffixIcon: IconButton(
icon: Icon(
_isConfirmPasswordVisible ? Icons.visibility : Icons.visibility_off,
),
onPressed: () {
setState(() {
_isConfirmPasswordVisible = !_isConfirmPasswordVisible;
});
},
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return '비밀번호를 다시 입력해주세요';
}
if (value != _passwordController.text) {
return '비밀번호가 일치하지 않습니다';
}
return null;
},
),
const SizedBox(height: 24),
// 회원가입 버튼
ElevatedButton(
onPressed: authService.isLoading ? null : _register,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
),
child: authService.isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text(
'회원가입',
style: TextStyle(fontSize: 16),
),
),
const SizedBox(height: 16),
// 로그인 화면으로 돌아가기
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('이미 계정이 있으신가요?'),
TextButton(
onPressed: () {
Navigator.pop(context); // 로그인 화면으로 돌아가기
},
child: const Text('로그인'),
),
],
),
],
),
),
),
),
),
);
}
}
@@ -0,0 +1,367 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../models/member_model.dart';
import '../../services/auth_service.dart';
import '../../services/club_service.dart';
import '../../services/member_service.dart';
import '../../utils/enum_mappings.dart';
class ClubSettingsScreen extends StatefulWidget {
static const routeName = '/club-settings';
const ClubSettingsScreen({super.key});
@override
_ClubSettingsScreenState createState() => _ClubSettingsScreenState();
}
class _ClubSettingsScreenState extends State<ClubSettingsScreen> {
final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController();
final _descriptionController = TextEditingController();
final _locationController = TextEditingController();
final _femaleHandicapController = TextEditingController();
String? _selectedAverageCalculationPeriod;
String? _selectedOwnerId;
List<Member> _members = [];
bool _isLoading = true;
bool _hasEditPermission = false;
@override
void initState() {
super.initState();
_loadData();
}
@override
void dispose() {
_nameController.dispose();
_descriptionController.dispose();
_locationController.dispose();
_femaleHandicapController.dispose();
super.dispose();
}
Future<void> _loadData() async {
setState(() {
_isLoading = true;
});
try {
// 클럽 정보 로드
final clubService = Provider.of<ClubService>(context, listen: false);
final club = clubService.currentClub;
if (club != null) {
_nameController.text = club.name;
_descriptionController.text = club.description ?? '';
_locationController.text = club.location ?? '';
_femaleHandicapController.text = club.femaleHandicap?.toString() ?? '';
_selectedAverageCalculationPeriod =
club.averageCalculationPeriod ?? 'total';
_selectedOwnerId = club.ownerId;
}
// 회원 목록 로드
final memberService = Provider.of<MemberService>(context, listen: false);
await memberService.fetchClubMembers();
setState(() {
_members = memberService.members;
});
// 권한 확인
final authService = Provider.of<AuthService>(context, listen: false);
final currentUserId = authService.currentUser?.id;
// 현재 사용자가 클럽 소유자이거나 관리자인지 확인
if (currentUserId != null) {
final currentMember = _members.firstWhere(
(member) => member.userId == currentUserId,
orElse: () => Member(
id: '',
userId: '',
name: '',
memberType: '',
clubId: club?.id ?? '',
email: '',
isActive: true,
),
);
setState(() {
_hasEditPermission =
club?.ownerId == currentUserId || // 클럽 소유자
currentMember.memberType == 'manager' || // 클럽 매니저
authService.currentUser?.role == 'admin'; // 시스템 관리자
});
}
} catch (error) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('데이터 로드 중 오류가 발생했습니다: $error')));
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
Future<void> _saveClubInfo() async {
if (!_formKey.currentState!.validate()) {
return;
}
setState(() {
_isLoading = true;
});
try {
final clubService = Provider.of<ClubService>(context, listen: false);
final club = clubService.currentClub;
if (club != null) {
// 기본 업데이트 데이터 준비
final Map<String, dynamic> updates = {
'clubId': club.id,
'name': _nameController.text.trim(),
};
// 빈 문자열이 아닌 경우에만 추가 (null 값 명시적 처리)
if (_descriptionController.text.trim().isNotEmpty) {
updates['description'] = _descriptionController.text.trim();
} else if (_descriptionController.text.trim().isEmpty &&
club.description != null) {
// 기존에 값이 있었는데 지금 비어있으면 명시적으로 null 설정
updates['description'] = null;
}
if (_locationController.text.trim().isNotEmpty) {
updates['location'] = _locationController.text.trim();
} else if (_locationController.text.trim().isEmpty &&
club.location != null) {
updates['location'] = null;
}
if (_femaleHandicapController.text.trim().isNotEmpty) {
updates['femaleHandicap'] = int.parse(
_femaleHandicapController.text.trim(),
);
}
if (_selectedAverageCalculationPeriod != null) {
updates['averageCalculationPeriod'] =
_selectedAverageCalculationPeriod;
}
if (_selectedOwnerId != null) {
updates['ownerId'] = _selectedOwnerId;
}
// 모임장 변경 시에만 이메일 정보 추가
if (_selectedOwnerId != club.ownerId) {
// 모임장이 변경된 경우 해당 회원의 이메일 정보 찾기
final selectedMember = _members.firstWhere(
(member) => member.userId == _selectedOwnerId,
orElse: () => Member(
id: '',
userId: '',
name: '',
memberType: '',
clubId: club.id,
email: '',
isActive: true,
),
);
// 이메일이 있는 경우에만 추가 (빈 문자열이나 null은 전송하지 않음)
if (selectedMember.email.isNotEmpty) {
updates['ownerEmail'] = selectedMember.email;
}
}
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')),
);
}
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('클럽 설정')),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: Column(
children: [
if (!_hasEditPermission)
Container(
width: double.infinity,
padding: const EdgeInsets.all(16.0),
color: Colors.amber.shade100,
child: const Text(
'권한 알림: 클럽 설정을 변경하려면 클럽 소유자 또는 관리자 권한이 필요합니다.',
style: TextStyle(fontWeight: FontWeight.bold),
),
),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 클럽 이름
TextFormField(
controller: _nameController,
decoration: const InputDecoration(
labelText: '클럽 이름',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return '클럽 이름을 입력해주세요';
}
return null;
},
enabled: _hasEditPermission,
),
// 설명
const SizedBox(height: 16),
TextFormField(
controller: _descriptionController,
decoration: const InputDecoration(
labelText: '설명',
border: OutlineInputBorder(),
),
maxLines: 3,
enabled: _hasEditPermission,
),
// 위치
const SizedBox(height: 16),
TextFormField(
controller: _locationController,
decoration: const InputDecoration(
labelText: '위치',
border: OutlineInputBorder(),
),
enabled: _hasEditPermission,
),
// 여성 기본 핸디캡
const SizedBox(height: 16),
TextFormField(
controller: _femaleHandicapController,
decoration: const InputDecoration(
labelText: '여성 기본 핸디캡',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
validator: (value) {
if (value != null && value.isNotEmpty) {
final handicap = int.tryParse(value);
if (handicap == null) {
return '숫자를 입력해주세요';
}
if (handicap < 0) {
return '0 이상의 값을 입력해주세요';
}
}
return null;
},
enabled: _hasEditPermission,
),
// 평균 산정 기준
const SizedBox(height: 16),
DropdownButtonFormField<String>(
decoration: const InputDecoration(
labelText: '평균 산정 기준',
border: OutlineInputBorder(),
),
value: _selectedAverageCalculationPeriod,
items: averageCalculationPeriodOptions.entries
.map(
(entry) => DropdownMenuItem<String>(
value: entry.key,
child: Text(entry.value),
),
)
.toList(),
onChanged: _hasEditPermission
? (value) {
setState(() {
_selectedAverageCalculationPeriod = value;
});
}
: null,
),
// 모임장 설정
const SizedBox(height: 16),
DropdownButtonFormField<String>(
decoration: const InputDecoration(
labelText: '모임장 설정',
border: OutlineInputBorder(),
),
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,
),
// 저장 버튼
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _hasEditPermission
? _saveClubInfo
: null,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(
vertical: 16.0,
),
),
child: const Text('저장'),
),
),
],
),
),
),
),
],
),
);
}
}
@@ -0,0 +1,394 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:intl/intl.dart';
import '../../services/auth_service.dart';
import '../../services/club_service.dart';
import '../../services/member_service.dart';
import '../../services/event_service.dart';
import '../../models/club_model.dart';
import 'club_settings_screen.dart';
class DashboardScreen extends StatefulWidget {
const DashboardScreen({super.key});
@override
State<DashboardScreen> createState() => _DashboardScreenState();
}
class _DashboardScreenState extends State<DashboardScreen> {
bool _isLoading = false;
bool _isInit = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (!_isInit) {
_loadDashboardData();
_isInit = true;
}
}
Future<void> _loadDashboardData() async {
setState(() {
_isLoading = true;
});
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!);
}
// 회원 및 이벤트 서비스 초기화
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!);
// 회원 및 이벤트 데이터 로드
await Future.wait([
memberService.fetchClubMembers(),
eventService.fetchClubEvents(),
]);
}
} catch (e) {
// 오류 처리
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('데이터 로드 중 오류가 발생했습니다: $e')),
);
} finally {
setState(() {
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: _isLoading
? const Center(child: CircularProgressIndicator())
: RefreshIndicator(
onRefresh: _loadDashboardData,
child: Consumer3<ClubService, MemberService, EventService>(
builder: (context, clubService, memberService, eventService, _) {
final club = clubService.currentClub;
if (club == null) {
return const Center(
child: Text('클럽 정보를 불러올 수 없습니다'),
);
}
return SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 클럽 정보 카드
_buildClubInfoCard(club),
const SizedBox(height: 16),
// 통계 카드들
_buildStatisticsCards(
memberCount: memberService.members.length,
eventCount: eventService.events.length,
),
const SizedBox(height: 16),
// 최근 이벤트 목록
_buildRecentEventsList(eventService),
],
),
);
},
),
),
);
}
Widget _buildClubInfoCard(Club club) {
return Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Row(
children: [
if (club.logo != null)
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
club.logo!,
width: 60,
height: 60,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Container(
width: 60,
height: 60,
color: Colors.grey[300],
child: const Icon(Icons.sports_golf),
);
},
),
)
else
Container(
width: 60,
height: 60,
decoration: BoxDecoration(
color: Colors.blue[100],
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.sports_golf,
size: 30,
color: Colors.blue,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
club.name,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
if (club.description != null)
Text(
club.description!,
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
),
IconButton(
icon: const Icon(Icons.settings),
tooltip: '클럽 설정',
onPressed: () {
Navigator.of(context).pushNamed(ClubSettingsScreen.routeName);
},
),
],
),
const SizedBox(height: 16),
if (club.address != null)
_buildInfoRow(Icons.location_on, club.address!),
if (club.phone != null)
_buildInfoRow(Icons.phone, club.phone!),
if (club.email != null)
_buildInfoRow(Icons.email, club.email!),
if (club.website != null)
_buildInfoRow(Icons.language, club.website!),
],
),
),
);
}
Widget _buildInfoRow(IconData icon, String text) {
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Row(
children: [
Icon(icon, size: 16, color: Colors.grey[600]),
const SizedBox(width: 8),
Expanded(
child: Text(
text,
style: TextStyle(fontSize: 14, color: Colors.grey[800]),
),
),
],
),
);
}
Widget _buildStatisticsCards({required int memberCount, required int eventCount}) {
return Row(
children: [
Expanded(
child: _buildStatCard(
title: '회원',
value: memberCount.toString(),
icon: Icons.people,
color: Colors.blue,
),
),
const SizedBox(width: 16),
Expanded(
child: _buildStatCard(
title: '이벤트',
value: eventCount.toString(),
icon: Icons.event,
color: Colors.orange,
),
),
],
);
}
Widget _buildStatCard({
required String title,
required String value,
required IconData icon,
required Color color,
}) {
return Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
Icon(icon, size: 32, color: color),
const SizedBox(height: 8),
Text(
value,
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: color,
),
),
Text(
title,
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
),
),
],
),
),
);
}
Widget _buildRecentEventsList(EventService eventService) {
final events = eventService.events;
if (events.isEmpty) {
return const Card(
child: Padding(
padding: EdgeInsets.all(16.0),
child: Center(
child: Text('예정된 이벤트가 없습니다'),
),
),
);
}
// 날짜 기준으로 정렬 (가까운 날짜순)
final sortedEvents = [...events];
sortedEvents.sort((a, b) => a.startDate.compareTo(b.startDate));
// 앞으로 진행될 이벤트만 필터링 (최대 3개)
final upcomingEvents = sortedEvents
.where((event) => event.startDate.isAfter(DateTime.now()))
.take(3)
.toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'다가오는 이벤트',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
TextButton(
onPressed: () {
// 이벤트 목록 화면으로 이동 (추후 구현)
},
child: const Text('모두 보기'),
),
],
),
const SizedBox(height: 8),
...upcomingEvents.map((event) => _buildEventCard(event)),
],
);
}
Widget _buildEventCard(event) {
final dateFormat = DateFormat('yyyy년 MM월 dd일 HH:mm');
return Card(
margin: const EdgeInsets.only(bottom: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
title: Text(
event.title,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
Text(
dateFormat.format(event.startDate),
style: TextStyle(
color: Colors.grey[700],
),
),
if (event.location != null)
Text(
event.location!,
style: TextStyle(
color: Colors.grey[600],
),
),
],
),
trailing: const Icon(Icons.arrow_forward_ios, size: 16),
onTap: () {
// 이벤트 상세 화면으로 이동 (추후 구현)
},
),
);
}
}
+671
View File
@@ -0,0 +1,671 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:intl/intl.dart';
import '../../services/auth_service.dart';
import '../../services/club_service.dart';
import '../../services/event_service.dart';
import '../../models/event_model.dart';
class EventsScreen extends StatefulWidget {
const EventsScreen({super.key});
@override
State<EventsScreen> createState() => _EventsScreenState();
}
class _EventsScreenState extends State<EventsScreen> {
bool _isInit = false;
String _searchQuery = '';
final TextEditingController _searchController = TextEditingController();
String _filterType = '전체'; // '전체', '예정', '지난'
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (!_isInit) {
_loadEvents();
_isInit = true;
}
}
Future<void> _loadEvents() async {
try {
final authService = Provider.of<AuthService>(context, listen: false);
final clubService = Provider.of<ClubService>(context, listen: false);
final eventService = Provider.of<EventService>(context, listen: false);
if (clubService.currentClub != null) {
eventService.initialize(authService.token!);
await eventService.fetchClubEvents();
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('이벤트 목록을 불러오는데 실패했습니다: $e')),
);
}
}
}
List<Event> _getFilteredEvents(List<Event> events) {
// 검색어 필터링
List<Event> filteredEvents = events;
if (_searchQuery.isNotEmpty) {
final query = _searchQuery.toLowerCase();
filteredEvents = filteredEvents.where((event) {
return event.title.toLowerCase().contains(query) ||
(event.description?.toLowerCase().contains(query) ?? false) ||
(event.location?.toLowerCase().contains(query) ?? false);
}).toList();
}
// 날짜 필터링
final now = DateTime.now();
if (_filterType == '예정') {
filteredEvents = filteredEvents.where((event) => event.startDate.isAfter(now)).toList();
} else if (_filterType == '지난') {
filteredEvents = filteredEvents.where((event) => event.startDate.isBefore(now)).toList();
}
// 날짜순 정렬
filteredEvents.sort((a, b) => a.startDate.compareTo(b.startDate));
return filteredEvents;
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: [
// 검색 바
Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: '이벤트 검색',
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
contentPadding: const EdgeInsets.symmetric(vertical: 0),
suffixIcon: _searchQuery.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
setState(() {
_searchQuery = '';
});
},
)
: null,
),
onChanged: (value) {
setState(() {
_searchQuery = value;
});
},
),
const SizedBox(height: 8),
// 필터 버튼들
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
_buildFilterChip('전체'),
const SizedBox(width: 8),
_buildFilterChip('예정'),
const SizedBox(width: 8),
_buildFilterChip('지난'),
],
),
),
],
),
),
// 이벤트 목록
Expanded(
child: Consumer<EventService>(
builder: (context, eventService, _) {
if (eventService.isLoading) {
return const Center(child: CircularProgressIndicator());
}
final filteredEvents = _getFilteredEvents(eventService.events);
if (filteredEvents.isEmpty) {
return Center(
child: Text(
_searchQuery.isEmpty
? '등록된 이벤트가 없습니다'
: '검색 결과가 없습니다',
),
);
}
return RefreshIndicator(
onRefresh: _loadEvents,
child: ListView.builder(
padding: const EdgeInsets.only(bottom: 16),
itemCount: filteredEvents.length,
itemBuilder: (context, index) {
final event = filteredEvents[index];
return _buildEventCard(event);
},
),
);
},
),
),
],
),
floatingActionButton: FloatingActionButton(
onPressed: () {
// 이벤트 추가 화면으로 이동 (추후 구현)
_showAddEventDialog();
},
backgroundColor: Colors.blue,
child: const Icon(Icons.add, color: Colors.white),
),
);
}
Widget _buildFilterChip(String label) {
final isSelected = _filterType == label;
return FilterChip(
label: Text(label),
selected: isSelected,
onSelected: (selected) {
setState(() {
_filterType = selected ? label : '전체';
});
},
backgroundColor: Colors.grey[200],
selectedColor: Colors.blue[100],
checkmarkColor: Colors.blue,
labelStyle: TextStyle(
color: isSelected ? Colors.blue[800] : Colors.black87,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
),
);
}
Widget _buildEventCard(Event event) {
final dateFormat = DateFormat('yyyy년 MM월 dd일 HH:mm');
final now = DateTime.now();
final isPast = event.startDate.isBefore(now);
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
leading: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: isPast ? Colors.grey[200] : Colors.blue[100],
borderRadius: BorderRadius.circular(8),
),
child: Icon(
Icons.event,
color: isPast ? Colors.grey[600] : Colors.blue,
),
),
title: Text(
event.title,
style: TextStyle(
fontWeight: FontWeight.bold,
color: isPast ? Colors.grey[600] : Colors.black,
),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
Text(
dateFormat.format(event.startDate),
style: TextStyle(
color: isPast ? Colors.grey[500] : Colors.grey[700],
),
),
if (event.location != null)
Text(
event.location!,
style: TextStyle(
color: isPast ? Colors.grey[500] : Colors.grey[600],
),
),
],
),
trailing: PopupMenuButton<String>(
icon: const Icon(Icons.more_vert),
onSelected: (value) {
if (value == 'edit') {
_showEditEventDialog(event);
} else if (value == 'delete') {
_showDeleteConfirmationDialog(event);
}
},
itemBuilder: (context) => [
const PopupMenuItem<String>(
value: 'edit',
child: Row(
children: [
Icon(Icons.edit, size: 18),
SizedBox(width: 8),
Text('수정'),
],
),
),
const PopupMenuItem<String>(
value: 'delete',
child: Row(
children: [
Icon(Icons.delete, size: 18, color: Colors.red),
SizedBox(width: 8),
Text('삭제', style: TextStyle(color: Colors.red)),
],
),
),
],
),
onTap: () {
// 이벤트 상세 화면으로 이동 (추후 구현)
_showEventDetailsDialog(event);
},
),
);
}
// 이벤트 추가 다이얼로그
void _showAddEventDialog() {
final titleController = TextEditingController();
final descriptionController = TextEditingController();
final locationController = TextEditingController();
DateTime selectedDate = DateTime.now().add(const Duration(days: 1));
TimeOfDay selectedTime = TimeOfDay.now();
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('이벤트 추가'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: titleController,
decoration: const InputDecoration(
labelText: '제목',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
TextField(
controller: descriptionController,
maxLines: 3,
decoration: const InputDecoration(
labelText: '설명',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
TextField(
controller: locationController,
decoration: const InputDecoration(
labelText: '장소',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
ListTile(
title: const Text('날짜'),
subtitle: Text(
DateFormat('yyyy년 MM월 dd일').format(selectedDate),
),
trailing: const Icon(Icons.calendar_today),
onTap: () async {
final pickedDate = await showDatePicker(
context: context,
initialDate: selectedDate,
firstDate: DateTime.now(),
lastDate: DateTime.now().add(const Duration(days: 365)),
);
if (pickedDate != null && context.mounted) {
setState(() {
selectedDate = pickedDate;
});
}
},
),
ListTile(
title: const Text('시간'),
subtitle: Text(selectedTime.format(context)),
trailing: const Icon(Icons.access_time),
onTap: () async {
final pickedTime = await showTimePicker(
context: context,
initialTime: selectedTime,
);
if (pickedTime != null && context.mounted) {
setState(() {
selectedTime = pickedTime;
});
}
},
),
],
),
),
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;
}
try {
final eventService = Provider.of<EventService>(context, listen: false);
final clubService = Provider.of<ClubService>(context, listen: false);
if (clubService.currentClub != null) {
// 날짜와 시간 결합
final eventDateTime = DateTime(
selectedDate.year,
selectedDate.month,
selectedDate.day,
selectedTime.hour,
selectedTime.minute,
);
await eventService.createEvent({
'title': titleController.text.trim(),
'description': descriptionController.text.trim(),
'location': locationController.text.trim(),
'startDate': eventDateTime.toIso8601String(),
'clubId': clubService.currentClub!.id,
'isActive': true,
});
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('추가'),
),
],
),
);
}
// 이벤트 수정 다이얼로그
void _showEditEventDialog(Event event) {
final titleController = TextEditingController(text: event.title);
final descriptionController = TextEditingController(text: event.description ?? '');
final locationController = TextEditingController(text: event.location ?? '');
DateTime selectedDate = event.startDate;
TimeOfDay selectedTime = TimeOfDay(
hour: event.startDate.hour,
minute: event.startDate.minute,
);
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('이벤트 수정'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: titleController,
decoration: const InputDecoration(
labelText: '제목',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
TextField(
controller: descriptionController,
maxLines: 3,
decoration: const InputDecoration(
labelText: '설명',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
TextField(
controller: locationController,
decoration: const InputDecoration(
labelText: '장소',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
ListTile(
title: const Text('날짜'),
subtitle: Text(
DateFormat('yyyy년 MM월 dd일').format(selectedDate),
),
trailing: const Icon(Icons.calendar_today),
onTap: () async {
final pickedDate = await showDatePicker(
context: context,
initialDate: selectedDate,
firstDate: DateTime.now().subtract(const Duration(days: 365)),
lastDate: DateTime.now().add(const Duration(days: 365)),
);
if (pickedDate != null && context.mounted) {
setState(() {
selectedDate = pickedDate;
});
}
},
),
ListTile(
title: const Text('시간'),
subtitle: Text(selectedTime.format(context)),
trailing: const Icon(Icons.access_time),
onTap: () async {
final pickedTime = await showTimePicker(
context: context,
initialTime: selectedTime,
);
if (pickedTime != null && context.mounted) {
setState(() {
selectedTime = pickedTime;
});
}
},
),
],
),
),
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;
}
try {
final eventService = Provider.of<EventService>(context, listen: false);
// 날짜와 시간 결합
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(),
});
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('저장'),
),
],
),
);
}
// 이벤트 상세 정보 다이얼로그
void _showEventDetailsDialog(Event event) {
final dateFormat = DateFormat('yyyy년 MM월 dd일 HH:mm');
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(event.title),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.calendar_today, size: 16, color: Colors.blue),
const SizedBox(width: 8),
Text(dateFormat.format(event.startDate)),
],
),
const SizedBox(height: 8),
if (event.location != null) ...[
Row(
children: [
const Icon(Icons.location_on, size: 16, color: Colors.blue),
const SizedBox(width: 8),
Text(event.location!),
],
),
const SizedBox(height: 8),
],
if (event.description != null) ...[
const Divider(),
const SizedBox(height: 8),
Text(
event.description!,
style: const TextStyle(fontSize: 16),
),
],
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('닫기'),
),
TextButton(
onPressed: () {
Navigator.of(context).pop();
_showEditEventDialog(event);
},
child: const Text('수정'),
),
],
),
);
}
// 이벤트 삭제 확인 다이얼로그
void _showDeleteConfirmationDialog(Event event) {
showDialog(
context: context,
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('삭제'),
),
],
),
);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,501 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:intl/intl.dart';
import 'package:fl_chart/fl_chart.dart';
import '../../services/auth_service.dart';
import '../../services/club_service.dart';
import '../../services/member_service.dart';
import '../../services/score_service.dart';
import '../../models/member_model.dart';
import '../../models/score_model.dart';
import 'member_statistics_screen.dart';
import 'score_entry_screen.dart';
class ClubStatisticsScreen extends StatefulWidget {
const ClubStatisticsScreen({super.key});
@override
State<ClubStatisticsScreen> createState() => _ClubStatisticsScreenState();
}
class _ClubStatisticsScreenState extends State<ClubStatisticsScreen> {
bool _isLoading = false;
bool _isInit = false;
Map<String, ScoreStatistics> _clubStatistics = {};
List<Member> _members = [];
Map<String, List<Score>> _recentScores = {};
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (!_isInit) {
_loadClubStatistics();
_isInit = true;
}
}
Future<void> _loadClubStatistics() async {
setState(() {
_isLoading = true;
});
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);
if (clubService.currentClub != null) {
// 점수 서비스 초기화
scoreService.initialize(authService.token!);
// 회원 목록 로드
await memberService.fetchClubMembers();
_members = memberService.members;
// 클럽 통계 로드
_clubStatistics = await scoreService.fetchClubStatistics();
// 각 회원별 최근 점수 로드 (최대 3개)
_recentScores = {};
for (final member in _members) {
try {
final scores = await scoreService.fetchMemberScores(member.id);
// 날짜 기준 정렬 (최신순)
scores.sort((a, b) => b.date.compareTo(a.date));
_recentScores[member.id] = scores.take(3).toList();
} catch (e) {
// 개별 회원 점수 로드 실패 시 빈 배열로 처리
_recentScores[member.id] = [];
}
}
}
} catch (e) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('데이터 로드 중 오류가 발생했습니다: $e')));
} finally {
setState(() {
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('클럽 통계')),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: RefreshIndicator(
onRefresh: _loadClubStatistics,
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 클럽 전체 통계
_buildClubOverallStats(),
const SizedBox(height: 24),
// 회원별 통계
const Text(
'회원별 통계',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
// 회원 목록
..._members.map((member) => _buildMemberStatsCard(member)),
],
),
),
),
floatingActionButton: FloatingActionButton(
onPressed: () async {
await Navigator.of(context).push(
MaterialPageRoute(builder: (context) => const ScoreEntryScreen()),
);
// 화면으로 돌아왔을 때 데이터 새로고침
_loadClubStatistics();
},
backgroundColor: Colors.blue,
child: const Icon(Icons.add, color: Colors.white),
),
);
}
Widget _buildClubOverallStats() {
// 클럽 전체 통계가 없는 경우
if (!_clubStatistics.containsKey('overall')) {
return const Card(
child: Padding(
padding: EdgeInsets.all(16.0),
child: Center(child: Text('클럽 전체 통계 데이터가 없습니다')),
),
);
}
final overallStats = _clubStatistics['overall']!;
return Card(
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'클럽 전체 통계',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: _buildStatItem(
label: '클럽 평균',
value: overallStats.average.toStringAsFixed(1),
icon: Icons.score,
color: Colors.blue,
),
),
Expanded(
child: _buildStatItem(
label: '최고 점수',
value: overallStats.highScore.toString(),
icon: Icons.emoji_events,
color: Colors.amber,
),
),
],
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: _buildStatItem(
label: '최저 점수',
value: overallStats.lowScore.toString(),
icon: Icons.arrow_downward,
color: Colors.red,
),
),
Expanded(
child: _buildStatItem(
label: '총 게임 수',
value: overallStats.gamesPlayed.toString(),
icon: Icons.sports,
color: Colors.green,
),
),
],
),
// 클럽 평균 점수 추이 그래프
const SizedBox(height: 24),
const Text(
'클럽 평균 점수 추이',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
SizedBox(height: 180, child: _buildClubAverageChart()),
// 추가 통계 정보
if (overallStats.additionalStats != null) ...[
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 8),
...overallStats.additionalStats!.entries.map((entry) {
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(entry.key, style: const TextStyle(fontSize: 14)),
Text(
entry.value.toString(),
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
],
),
);
}),
],
],
),
),
);
}
Widget _buildStatItem({
required String label,
required String value,
required IconData icon,
required Color color,
}) {
return Column(
children: [
Icon(icon, size: 28, color: color),
const SizedBox(height: 8),
Text(
value,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: color,
),
),
Text(label, style: TextStyle(fontSize: 12, color: Colors.grey[600])),
],
);
}
Widget _buildMemberStatsCard(Member member) {
// 회원 통계 정보
final memberStats = _clubStatistics[member.id];
// 회원 최근 점수
final recentScores = _recentScores[member.id] ?? [];
return Card(
margin: const EdgeInsets.only(bottom: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: InkWell(
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => MemberStatisticsScreen(memberId: member.id),
),
);
},
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 회원 정보
Row(
children: [
CircleAvatar(
radius: 24,
backgroundColor: Colors.blue.shade100,
backgroundImage: member.profileImage != null
? NetworkImage(member.profileImage!)
: null,
child: member.profileImage == null
? Text(
member.name.isNotEmpty
? member.name[0].toUpperCase()
: '?',
style: const TextStyle(
fontSize: 20,
color: Colors.blue,
fontWeight: FontWeight.bold,
),
)
: null,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
member.name,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
if (memberStats != null)
Text(
'평균: ${memberStats.average.toStringAsFixed(1)} | 최고: ${memberStats.highScore} | 게임: ${memberStats.gamesPlayed}',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
],
),
),
const Icon(Icons.chevron_right),
],
),
// 최근 점수 표시
if (recentScores.isNotEmpty) ...[
const SizedBox(height: 12),
const Divider(),
const SizedBox(height: 8),
const Text(
'최근 점수',
style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: recentScores.map((score) {
return Column(
children: [
Text(
score.totalScore.toString(),
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.blue,
),
),
Text(
DateFormat('MM/dd').format(score.date),
style: TextStyle(
fontSize: 10,
color: Colors.grey[600],
),
),
],
);
}).toList(),
),
],
],
),
),
),
);
}
Widget _buildClubAverageChart() {
// 클럽 통계가 없는 경우
if (!_clubStatistics.containsKey('overall')) {
return const Center(child: Text('통계 데이터가 없습니다'));
}
// 회원별 평균 점수 데이터 추출
final memberAverages = <String, double>{};
// 회원별 통계 정보에서 평균 점수 추출
_clubStatistics.forEach((key, stats) {
if (key != 'overall') {
// 회원 ID에 해당하는 회원 찾기
final member = _members.firstWhere(
(m) => m.id == key,
orElse: () => Member(
id: key,
userId: key,
name: 'Unknown',
email: '',
clubId: '',
isActive: true,
),
);
memberAverages[member.name] = stats.average;
}
});
// 평균 점수 기준으로 정렬 (내림차순)
final sortedEntries = memberAverages.entries.toList()
..sort((a, b) => b.value.compareTo(a.value));
// 최대 8명만 표시
final displayEntries = sortedEntries.length > 8
? sortedEntries.sublist(0, 8)
: sortedEntries;
// 그래프 데이터 생성
final barGroups = <BarChartGroupData>[];
for (int i = 0; i < displayEntries.length; i++) {
barGroups.add(
BarChartGroupData(
x: i,
barRods: [
BarChartRodData(
toY: displayEntries[i].value,
color: Colors.blue,
width: 16,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(4),
topRight: Radius.circular(4),
),
),
],
),
);
}
// 최소/최대 점수 계산 (Y축 범위 설정용)
final minScore = displayEntries.isEmpty
? 0.0
: displayEntries.map((e) => e.value).reduce((a, b) => a < b ? a : b) -
10;
final maxScore = displayEntries.isEmpty
? 300.0
: displayEntries.map((e) => e.value).reduce((a, b) => a > b ? a : b) +
10;
return BarChart(
BarChartData(
alignment: BarChartAlignment.spaceAround,
maxY: maxScore,
minY: minScore < 0 ? 0 : minScore,
gridData: const FlGridData(show: true, horizontalInterval: 50),
borderData: FlBorderData(
show: true,
border: Border.all(color: Colors.grey.shade300),
),
titlesData: FlTitlesData(
show: true,
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (value, meta) {
final index = value.toInt();
if (index >= 0 && index < displayEntries.length) {
// 회원 이름 표시 (최대 5글자)
final name = displayEntries[index].key;
final shortName = name.length > 5
? '${name.substring(0, 4)}...'
: name;
return Text(shortName, style: const TextStyle(fontSize: 10));
}
return const SizedBox();
},
reservedSize: 30,
),
),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
interval: 50,
getTitlesWidget: (value, meta) {
return Text(
value.toInt().toString(),
style: const TextStyle(fontSize: 10),
);
},
reservedSize: 40,
),
),
),
barGroups: barGroups,
),
);
}
}
@@ -0,0 +1,662 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:intl/intl.dart';
import 'package:fl_chart/fl_chart.dart';
import '../../services/auth_service.dart';
import '../../services/club_service.dart';
import '../../services/member_service.dart';
import '../../services/score_service.dart';
import '../../models/member_model.dart';
import '../../models/score_model.dart';
import 'score_entry_screen.dart';
class MemberStatisticsScreen extends StatefulWidget {
final String memberId;
const MemberStatisticsScreen({super.key, required this.memberId});
@override
State<MemberStatisticsScreen> createState() => _MemberStatisticsScreenState();
}
class _MemberStatisticsScreenState extends State<MemberStatisticsScreen>
with SingleTickerProviderStateMixin {
bool _isLoading = false;
bool _isInit = false;
late TabController _tabController;
Member? _member;
List<Score> _scores = [];
ScoreStatistics? _statistics;
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (!_isInit) {
_loadMemberData();
_isInit = true;
}
}
Future<void> _loadMemberData() async {
setState(() {
_isLoading = true;
});
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);
if (clubService.currentClub != null) {
// 회원 정보 로드
_member = await memberService.fetchMemberById(widget.memberId);
// 점수 서비스 초기화
scoreService.initialize(authService.token!);
// 회원 점수 및 통계 로드
await Future.wait([
scoreService.fetchMemberScores(widget.memberId).then((scores) {
_scores = scores;
}),
scoreService.fetchMemberStatistics(widget.memberId).then((stats) {
_statistics = stats;
}),
]);
}
} catch (e) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('데이터 로드 중 오류가 발생했습니다: $e')));
} finally {
setState(() {
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(_member?.name ?? '회원 통계'),
bottom: TabBar(
controller: _tabController,
tabs: const [
Tab(text: '통계'),
Tab(text: '점수 기록'),
],
),
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: TabBarView(
controller: _tabController,
children: [_buildStatisticsTab(), _buildScoresTab()],
),
floatingActionButton: FloatingActionButton(
onPressed: () async {
await Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => ScoreEntryScreen(memberId: widget.memberId),
),
);
// 화면으로 돌아왔을 때 데이터 새로고침
_loadMemberData();
},
backgroundColor: Colors.blue,
child: const Icon(Icons.add, color: Colors.white),
),
);
}
Widget _buildStatisticsTab() {
if (_statistics == null) {
return const Center(child: Text('통계 데이터가 없습니다'));
}
return RefreshIndicator(
onRefresh: _loadMemberData,
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 회원 정보 카드
if (_member != null) _buildMemberInfoCard(),
const SizedBox(height: 24),
// 주요 통계 카드
_buildMainStatsCard(),
const SizedBox(height: 24),
// 추가 통계 정보
if (_statistics!.additionalStats != null)
_buildAdditionalStatsCard(),
// 통계 그래프
const SizedBox(height: 24),
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'점수 추이',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
SizedBox(height: 200, child: _buildScoreChart()),
],
),
),
),
],
),
),
);
}
Widget _buildMemberInfoCard() {
return Card(
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
CircleAvatar(
radius: 30,
backgroundColor: Colors.blue.shade100,
backgroundImage: _member!.profileImage != null
? NetworkImage(_member!.profileImage!)
: null,
child: _member!.profileImage == null
? Text(
_member!.name.isNotEmpty
? _member!.name[0].toUpperCase()
: '?',
style: const TextStyle(
fontSize: 24,
color: Colors.blue,
fontWeight: FontWeight.bold,
),
)
: null,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_member!.name,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
_member!.email,
style: TextStyle(color: Colors.grey[600]),
),
if (_member!.phone != null)
Text(
_member!.phone!,
style: TextStyle(color: Colors.grey[600]),
),
],
),
),
],
),
),
);
}
Widget _buildMainStatsCard() {
return Card(
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'주요 통계',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: _buildStatItem(
label: '평균 점수',
value: _statistics!.average.toStringAsFixed(1),
icon: Icons.score,
color: Colors.blue,
),
),
Expanded(
child: _buildStatItem(
label: '최고 점수',
value: _statistics!.highScore.toString(),
icon: Icons.emoji_events,
color: Colors.amber,
),
),
],
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: _buildStatItem(
label: '최저 점수',
value: _statistics!.lowScore.toString(),
icon: Icons.arrow_downward,
color: Colors.red,
),
),
Expanded(
child: _buildStatItem(
label: '게임 수',
value: _statistics!.gamesPlayed.toString(),
icon: Icons.sports,
color: Colors.green,
),
),
],
),
],
),
),
);
}
Widget _buildStatItem({
required String label,
required String value,
required IconData icon,
required Color color,
}) {
return Column(
children: [
Icon(icon, size: 32, color: color),
const SizedBox(height: 8),
Text(
value,
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: color,
),
),
Text(label, style: TextStyle(fontSize: 14, color: Colors.grey[600])),
],
);
}
Widget _buildAdditionalStatsCard() {
final additionalStats = _statistics!.additionalStats!;
return Card(
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'추가 통계',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
...additionalStats.entries.map((entry) {
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(entry.key, style: const TextStyle(fontSize: 16)),
Text(
entry.value.toString(),
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
],
),
);
}),
],
),
),
);
}
Widget _buildScoresTab() {
if (_scores.isEmpty) {
return const Center(child: Text('기록된 점수가 없습니다'));
}
// 날짜 기준으로 정렬 (최신순)
final sortedScores = [..._scores];
sortedScores.sort((a, b) => b.date.compareTo(a.date));
return RefreshIndicator(
onRefresh: _loadMemberData,
child: ListView.builder(
padding: const EdgeInsets.all(8.0),
itemCount: sortedScores.length,
itemBuilder: (context, index) {
final score = sortedScores[index];
return _buildScoreCard(score);
},
),
);
}
Widget _buildScoreCard(Score score) {
final dateFormat = DateFormat('yyyy년 MM월 dd일');
return Card(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: ExpansionTile(
title: Row(
children: [
Text(
score.totalScore.toString(),
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.blue,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
dateFormat.format(score.date),
style: const TextStyle(fontSize: 14),
),
if (score.notes != null && score.notes!.isNotEmpty)
Text(
score.notes!,
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'프레임 점수',
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
_buildFrameScores(score.frames),
if (score.notes != null && score.notes!.isNotEmpty) ...[
const SizedBox(height: 16),
const Text(
'메모',
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
Text(score.notes!),
],
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton.icon(
icon: const Icon(Icons.edit, size: 16),
label: const Text('수정'),
onPressed: () {
// 점수 수정 기능 (추후 구현)
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('점수 수정 기능은 아직 개발 중입니다')),
);
},
),
const SizedBox(width: 8),
TextButton.icon(
icon: const Icon(
Icons.delete,
size: 16,
color: Colors.red,
),
label: const Text(
'삭제',
style: TextStyle(color: Colors.red),
),
onPressed: () {
_showDeleteConfirmationDialog(score);
},
),
],
),
],
),
),
],
),
);
}
Widget _buildFrameScores(List<int> frames) {
return Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: List.generate(10, (index) {
return Expanded(
child: Container(
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey[300]!),
),
child: Column(
children: [
Text(
'${index + 1}',
style: TextStyle(fontSize: 10, color: Colors.grey[600]),
),
const SizedBox(height: 4),
Text(
index < frames.length ? frames[index].toString() : '-',
style: const TextStyle(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
],
),
),
);
}),
),
);
}
Widget _buildScoreChart() {
if (_scores.isEmpty) {
return const Center(child: Text('점수 데이터가 없습니다'));
}
// 날짜 기준으로 정렬 (오래된 순)
final sortedScores = [..._scores];
sortedScores.sort((a, b) => a.date.compareTo(b.date));
// 최대 10개의 최근 점수만 표시
final displayScores = sortedScores.length > 10
? sortedScores.sublist(sortedScores.length - 10)
: sortedScores;
// 최소/최대 점수 계산 (Y축 범위 설정용)
final minScore =
displayScores.map((s) => s.totalScore).reduce((a, b) => a < b ? a : b) -
10;
final maxScore =
displayScores.map((s) => s.totalScore).reduce((a, b) => a > b ? a : b) +
10;
// 그래프 데이터 포인트 생성
final spots = <FlSpot>[];
for (int i = 0; i < displayScores.length; i++) {
spots.add(FlSpot(i.toDouble(), displayScores[i].totalScore.toDouble()));
}
return LineChart(
LineChartData(
gridData: FlGridData(
show: true,
drawVerticalLine: true,
horizontalInterval: 20,
verticalInterval: 1,
),
titlesData: FlTitlesData(
show: true,
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 30,
interval: 1,
getTitlesWidget: (value, meta) {
final index = value.toInt();
if (index >= 0 && index < displayScores.length) {
return Text(
DateFormat('MM/dd').format(displayScores[index].date),
style: const TextStyle(fontSize: 10),
);
}
return const SizedBox();
},
),
),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
interval: 50,
getTitlesWidget: (value, meta) {
return Text(
value.toInt().toString(),
style: const TextStyle(fontSize: 10),
);
},
reservedSize: 40,
),
),
),
borderData: FlBorderData(
show: true,
border: Border.all(color: Colors.grey.shade300),
),
minX: 0,
maxX: displayScores.length - 1.0,
minY: minScore.toDouble(),
maxY: maxScore.toDouble(),
lineBarsData: [
LineChartBarData(
spots: spots,
isCurved: false,
color: Colors.blue,
barWidth: 3,
isStrokeCapRound: true,
dotData: const FlDotData(show: true),
belowBarData: BarAreaData(
show: true,
color: Colors.blue.withOpacity(0.2),
),
),
],
),
);
}
void _showDeleteConfirmationDialog(Score score) {
showDialog(
context: context,
builder: (context) => 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('삭제'),
),
],
),
);
}
}
@@ -0,0 +1,378 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:intl/intl.dart';
import '../../services/club_service.dart';
import '../../services/member_service.dart';
import '../../services/event_service.dart';
import '../../services/score_service.dart';
class ScoreEntryScreen extends StatefulWidget {
final String? memberId;
final String? eventId;
const ScoreEntryScreen({
super.key,
this.memberId,
this.eventId,
});
@override
State<ScoreEntryScreen> createState() => _ScoreEntryScreenState();
}
class _ScoreEntryScreenState extends State<ScoreEntryScreen> {
final _formKey = GlobalKey<FormState>();
bool _isLoading = false;
// 점수 입력 관련 상태
final List<TextEditingController> _frameControllers = List.generate(
10,
(_) => TextEditingController(),
);
final TextEditingController _notesController = TextEditingController();
// 선택된 회원 및 이벤트
String? _selectedMemberId;
String? _selectedEventId;
DateTime _selectedDate = DateTime.now();
@override
void initState() {
super.initState();
_selectedMemberId = widget.memberId;
_selectedEventId = widget.eventId;
}
@override
void dispose() {
for (var controller in _frameControllers) {
controller.dispose();
}
_notesController.dispose();
super.dispose();
}
// 총점 계산
int _calculateTotalScore() {
int total = 0;
for (var controller in _frameControllers) {
final value = int.tryParse(controller.text) ?? 0;
total += value;
}
return total;
}
// 점수 저장
Future<void> _saveScore() async {
if (!_formKey.currentState!.validate()) {
return;
}
if (_selectedMemberId == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('회원을 선택해주세요')),
);
return;
}
setState(() {
_isLoading = true;
});
try {
final scoreService = Provider.of<ScoreService>(context, listen: false);
final clubService = Provider.of<ClubService>(context, listen: false);
if (clubService.currentClub != null) {
// 프레임 점수 배열 생성
final frames = _frameControllers
.map((controller) => int.tryParse(controller.text) ?? 0)
.toList();
// 총점 계산
final totalScore = _calculateTotalScore();
// 점수 데이터 생성
final scoreData = {
'memberId': _selectedMemberId,
'eventId': _selectedEventId,
'clubId': clubService.currentClub!.id,
'frames': frames,
'totalScore': totalScore,
'date': _selectedDate.toIso8601String(),
'notes': _notesController.text.isNotEmpty ? _notesController.text : null,
};
// 점수 저장
await scoreService.addScore(scoreData);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('점수가 저장되었습니다')),
);
Navigator.of(context).pop();
}
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('점수 저장에 실패했습니다: $e')),
);
} finally {
setState(() {
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('점수 입력'),
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: Form(
key: _formKey,
child: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 회원 선택
_buildMemberSelector(),
const SizedBox(height: 16),
// 이벤트 선택
_buildEventSelector(),
const SizedBox(height: 16),
// 날짜 선택
_buildDateSelector(),
const SizedBox(height: 24),
// 프레임 점수 입력
_buildFrameInputs(),
const SizedBox(height: 24),
// 총점 표시
_buildTotalScore(),
const SizedBox(height: 16),
// 메모 입력
TextFormField(
controller: _notesController,
decoration: const InputDecoration(
labelText: '메모',
border: OutlineInputBorder(),
),
maxLines: 3,
),
const SizedBox(height: 24),
// 저장 버튼
SizedBox(
width: double.infinity,
height: 50,
child: ElevatedButton(
onPressed: _saveScore,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
),
child: const Text('점수 저장'),
),
),
],
),
),
),
);
}
Widget _buildMemberSelector() {
return Consumer<MemberService>(
builder: (context, memberService, _) {
final members = memberService.members;
return DropdownButtonFormField<String>(
decoration: const InputDecoration(
labelText: '회원 선택',
border: OutlineInputBorder(),
),
value: _selectedMemberId,
items: members.map((member) {
return DropdownMenuItem<String>(
value: member.id,
child: Text(member.name),
);
}).toList(),
onChanged: (value) {
setState(() {
_selectedMemberId = value;
});
},
validator: (value) {
if (value == null || value.isEmpty) {
return '회원을 선택해주세요';
}
return null;
},
);
},
);
}
Widget _buildEventSelector() {
return Consumer<EventService>(
builder: (context, eventService, _) {
final events = eventService.events;
return DropdownButtonFormField<String>(
decoration: const InputDecoration(
labelText: '이벤트 선택 (선택사항)',
border: OutlineInputBorder(),
),
value: _selectedEventId,
items: [
const DropdownMenuItem<String>(
value: null,
child: Text('이벤트 없음'),
),
...events.map((event) {
return DropdownMenuItem<String>(
value: event.id,
child: Text(event.title),
);
}),
],
onChanged: (value) {
setState(() {
_selectedEventId = value;
});
},
);
},
);
}
Widget _buildDateSelector() {
return InkWell(
onTap: () async {
final pickedDate = await showDatePicker(
context: context,
initialDate: _selectedDate,
firstDate: DateTime.now().subtract(const Duration(days: 365)),
lastDate: DateTime.now(),
);
if (pickedDate != null) {
setState(() {
_selectedDate = pickedDate;
});
}
},
child: InputDecorator(
decoration: const InputDecoration(
labelText: '날짜',
border: OutlineInputBorder(),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(DateFormat('yyyy년 MM월 dd일').format(_selectedDate)),
const Icon(Icons.calendar_today),
],
),
),
);
}
Widget _buildFrameInputs() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'프레임 점수',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 5,
childAspectRatio: 1.5,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
),
itemCount: 10,
itemBuilder: (context, index) {
return TextFormField(
controller: _frameControllers[index],
keyboardType: TextInputType.number,
textAlign: TextAlign.center,
decoration: InputDecoration(
labelText: '${index + 1}프레임',
border: const OutlineInputBorder(),
),
onChanged: (_) {
setState(() {});
},
validator: (value) {
if (value == null || value.isEmpty) {
return '입력';
}
final score = int.tryParse(value);
if (score == null) {
return '숫자';
}
if (score < 0 || score > 30) {
return '0-30';
}
return null;
},
);
},
),
],
);
}
Widget _buildTotalScore() {
final totalScore = _calculateTotalScore();
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.blue.shade200),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'총점',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
Text(
'$totalScore',
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.blue,
),
),
],
),
);
}
}
@@ -0,0 +1,393 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../models/subscription_model.dart';
import '../../services/subscription_service.dart';
class SubscriptionDetailsScreen extends StatefulWidget {
final SubscriptionPlan plan;
final bool isYearly;
const SubscriptionDetailsScreen({
super.key,
required this.plan,
required this.isYearly,
});
@override
State<SubscriptionDetailsScreen> createState() => _SubscriptionDetailsScreenState();
}
class _SubscriptionDetailsScreenState extends State<SubscriptionDetailsScreen> {
bool _isLoading = false;
@override
Widget build(BuildContext context) {
final price = widget.isYearly ? widget.plan.yearlyPrice : widget.plan.monthlyPrice;
final period = widget.isYearly ? '' : '';
return Scaffold(
appBar: AppBar(
title: Text('${widget.plan.name} 플랜'),
),
body: Stack(
children: [
SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 플랜 헤더
_buildPlanHeader(price, period),
const SizedBox(height: 24),
// 플랜 설명
Text(
widget.plan.description,
style: const TextStyle(fontSize: 16),
),
const SizedBox(height: 24),
// 플랜 혜택
const Text(
'플랜 혜택',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
...widget.plan.features.map((feature) => _buildFeatureItem(feature)),
const SizedBox(height: 24),
// 플랜 세부 정보
const Text(
'세부 정보',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
_buildDetailItem('최대 회원 수', '${widget.plan.maxMembers}'),
_buildDetailItem('최대 이벤트 수', '${widget.plan.maxEvents}'),
_buildDetailItem(
'고급 통계 기능',
widget.plan.hasAdvancedStats ? '제공' : '미제공',
isAvailable: widget.plan.hasAdvancedStats,
),
_buildDetailItem(
'맞춤형 기능',
widget.plan.hasCustomization ? '제공' : '미제공',
isAvailable: widget.plan.hasCustomization,
),
_buildDetailItem(
'우선 지원',
widget.plan.hasPriority ? '제공' : '미제공',
isAvailable: widget.plan.hasPriority,
),
const SizedBox(height: 24),
// 결제 정보
const Text(
'결제 정보',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
_buildDetailItem(
'결제 주기',
widget.isYearly ? '연간' : '월간',
),
_buildDetailItem(
'결제 금액',
'${_formatPrice(price)}/${widget.isYearly ? '' : ''}',
),
if (widget.isYearly)
_buildDetailItem(
'월 환산 금액',
'${_formatPrice(widget.plan.yearlyPrice / 12)}/월',
),
_buildDetailItem(
'자동 갱신',
'활성화 (설정에서 변경 가능)',
),
// 하단 여백 (버튼 높이만큼)
const SizedBox(height: 80),
],
),
),
// 하단 구독 버튼
Positioned(
left: 0,
right: 0,
bottom: 0,
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 10,
offset: const Offset(0, -5),
),
],
),
child: ElevatedButton(
onPressed: _isLoading ? null : () => _subscribe(context),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
minimumSize: const Size(double.infinity, 50),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: _isLoading
? const CircularProgressIndicator(color: Colors.white)
: Text(
widget.plan.type == SubscriptionPlanType.free
? '무료 플랜 시작하기'
: '구독하기',
),
),
),
),
],
),
);
}
Widget _buildPlanHeader(double price, String period) {
return Card(
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: _getPlanColor(),
width: 2,
),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Row(
children: [
Icon(
_getPlanIcon(),
color: _getPlanColor(),
size: 32,
),
const SizedBox(width: 16),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.plan.name,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
if (widget.plan.type == SubscriptionPlanType.premium)
Container(
margin: const EdgeInsets.only(top: 4),
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: Colors.amber.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.amber),
),
child: const Text(
'인기 플랜',
style: TextStyle(
color: Colors.amber,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
),
],
),
],
),
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${_formatPrice(price)}',
style: const TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 4),
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
'/$period',
style: const TextStyle(fontSize: 16),
),
),
],
),
if (widget.isYearly)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
'월 ₩${_formatPrice(widget.plan.yearlyPrice / 12)} (20% 할인)',
style: TextStyle(
color: Colors.green[700],
fontWeight: FontWeight.bold,
),
),
),
],
),
),
);
}
Widget _buildFeatureItem(String feature) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.check_circle, color: Colors.green, size: 20),
const SizedBox(width: 12),
Expanded(child: Text(feature, style: const TextStyle(fontSize: 16))),
],
),
);
}
Widget _buildDetailItem(String label, String value, {bool isAvailable = true}) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
children: [
Expanded(
flex: 2,
child: Text(
label,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
),
Expanded(
flex: 3,
child: Text(
value,
style: TextStyle(
fontSize: 16,
color: isAvailable ? null : Colors.grey,
),
),
),
],
),
);
}
Color _getPlanColor() {
switch (widget.plan.type) {
case SubscriptionPlanType.free:
return Colors.grey;
case SubscriptionPlanType.basic:
return Colors.blue;
case SubscriptionPlanType.premium:
return Colors.amber;
case SubscriptionPlanType.enterprise:
return Colors.purple;
}
}
IconData _getPlanIcon() {
switch (widget.plan.type) {
case SubscriptionPlanType.free:
return Icons.star_border;
case SubscriptionPlanType.basic:
return Icons.star_half;
case SubscriptionPlanType.premium:
return Icons.star;
case SubscriptionPlanType.enterprise:
return Icons.workspace_premium;
}
}
String _formatPrice(double price) {
if (price == 0) {
return '0';
}
// 천 단위 콤마 추가
final priceInt = price.toInt();
return priceInt.toString().replaceAllMapped(
RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'),
(Match m) => '${m[1]},',
);
}
Future<void> _subscribe(BuildContext context) async {
setState(() {
_isLoading = true;
});
try {
final subscriptionService = Provider.of<SubscriptionService>(context, listen: false);
final success = await subscriptionService.createSubscription(
widget.plan.type,
widget.isYearly,
);
if (success && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('구독이 성공적으로 완료되었습니다')),
);
Navigator.of(context).pop(); // 상세 화면 닫기
} else if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(subscriptionService.error ?? '구독 처리 중 오류가 발생했습니다'),
backgroundColor: Colors.red,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('오류: $e'),
backgroundColor: Colors.red,
),
);
}
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
}
@@ -0,0 +1,486 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../models/subscription_model.dart';
import '../../services/subscription_service.dart';
import 'subscription_details_screen.dart';
class SubscriptionScreen extends StatefulWidget {
const SubscriptionScreen({super.key});
@override
State<SubscriptionScreen> createState() => _SubscriptionScreenState();
}
class _SubscriptionScreenState extends State<SubscriptionScreen> {
bool _isYearly = true; // 기본값은 연간 구독
@override
void initState() {
super.initState();
// 화면 로드 시 구독 정보 조회
WidgetsBinding.instance.addPostFrameCallback((_) {
final subscriptionService = Provider.of<SubscriptionService>(
context,
listen: false,
);
subscriptionService.fetchCurrentSubscription();
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('구독 관리')),
body: Consumer<SubscriptionService>(
builder: (context, subscriptionService, child) {
if (subscriptionService.isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (subscriptionService.error != null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('오류가 발생했습니다', style: TextStyle(color: Colors.red[700])),
const SizedBox(height: 8),
Text(subscriptionService.error!),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () =>
subscriptionService.fetchCurrentSubscription(),
child: const Text('다시 시도'),
),
],
),
);
}
// 현재 구독 정보가 있는 경우
if (subscriptionService.hasSubscription) {
return _buildCurrentSubscription(subscriptionService);
}
// 구독 정보가 없는 경우 플랜 선택 화면
return _buildSubscriptionPlans(subscriptionService);
},
),
);
}
Widget _buildCurrentSubscription(SubscriptionService subscriptionService) {
final subscription = subscriptionService.currentSubscription!;
final plan = subscriptionService.availablePlans.firstWhere(
(plan) => plan.type == subscription.planType,
orElse: () => SubscriptionPlan.getDefaultPlans().first,
);
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 현재 구독 정보 카드
Card(
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: subscription.isActive ? Colors.green : Colors.grey,
width: 2,
),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.verified,
color: subscription.isActive
? Colors.green
: Colors.grey,
size: 28,
),
const SizedBox(width: 8),
Text(
'${plan.name} 플랜',
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: subscription.isActive
? Colors.green.withOpacity(0.1)
: Colors.grey.withOpacity(0.1),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: subscription.isActive
? Colors.green
: Colors.grey,
),
),
child: Text(
subscription.isActive ? '활성' : '만료됨',
style: TextStyle(
color: subscription.isActive
? Colors.green
: Colors.grey,
fontWeight: FontWeight.bold,
),
),
),
],
),
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 16),
_buildInfoRow('시작일', _formatDate(subscription.startDate)),
const SizedBox(height: 8),
_buildInfoRow('만료일', _formatDate(subscription.endDate)),
const SizedBox(height: 8),
_buildInfoRow(
'자동 갱신',
subscription.autoRenew ? '활성화' : '비활성화',
),
const SizedBox(height: 8),
_buildInfoRow('남은 기간', '${subscription.daysLeft}'),
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 16),
const Text(
'플랜 혜택',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
...plan.features.map(
(feature) => Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Row(
children: [
const Icon(
Icons.check_circle,
color: Colors.green,
size: 16,
),
const SizedBox(width: 8),
Text(feature),
],
),
),
),
],
),
),
),
const SizedBox(height: 24),
// 구독 관리 버튼
Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: subscription.isActive
? () => _showCancelDialog(subscriptionService)
: null,
icon: const Icon(Icons.cancel),
label: const Text('구독 취소'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red.shade100,
foregroundColor: Colors.red.shade700,
),
),
),
const SizedBox(width: 16),
Expanded(
child: ElevatedButton.icon(
onPressed: () => _toggleAutoRenew(subscriptionService),
icon: Icon(
subscription.autoRenew ? Icons.toggle_on : Icons.toggle_off,
),
label: Text(subscription.autoRenew ? '자동 갱신 끄기' : '자동 갱신 켜기'),
),
),
],
),
const SizedBox(height: 16),
// 플랜 변경 버튼
ElevatedButton.icon(
onPressed: () {
setState(() {
// 플랜 선택 화면으로 전환
subscriptionService.fetchCurrentSubscription();
});
},
icon: const Icon(Icons.upgrade),
label: const Text('플랜 변경하기'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
minimumSize: const Size(double.infinity, 50),
),
),
],
),
);
}
Widget _buildSubscriptionPlans(SubscriptionService subscriptionService) {
final plans = subscriptionService.availablePlans;
return Column(
children: [
// 연간/월간 선택 토글
Padding(
padding: const EdgeInsets.all(16),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('월간 구독'),
const SizedBox(width: 8),
Switch(
value: _isYearly,
onChanged: (value) {
setState(() {
_isYearly = value;
});
},
activeColor: Colors.blue,
),
const SizedBox(width: 8),
Row(
children: [
const Text('연간 구독'),
Container(
margin: const EdgeInsets.only(left: 8),
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: Colors.green.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.green),
),
child: const Text(
'20% 할인',
style: TextStyle(
color: Colors.green,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
),
],
),
],
),
),
// 플랜 목록
Expanded(
child: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: plans.length,
itemBuilder: (context, index) {
final plan = plans[index];
final price = _isYearly ? plan.yearlyPrice : plan.monthlyPrice;
final period = _isYearly ? '' : '';
return Card(
margin: const EdgeInsets.only(bottom: 16),
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: InkWell(
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => SubscriptionDetailsScreen(
plan: plan,
isYearly: _isYearly,
),
),
);
},
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
plan.name,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
if (plan.type == SubscriptionPlanType.premium)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.amber.withOpacity(0.1),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.amber),
),
child: const Text(
'인기',
style: TextStyle(
color: Colors.amber,
fontWeight: FontWeight.bold,
),
),
),
],
),
const SizedBox(height: 8),
Text(plan.description),
const SizedBox(height: 16),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${_formatPrice(price)}',
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 4),
Text('/$period'),
const Spacer(),
const Icon(Icons.arrow_forward),
],
),
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 8),
...plan.features
.take(3)
.map(
(feature) => Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Row(
children: [
const Icon(
Icons.check_circle,
color: Colors.green,
size: 16,
),
const SizedBox(width: 8),
Text(feature),
],
),
),
),
if (plan.features.length > 3)
Text(
'${plan.features.length - 3}개 혜택',
style: TextStyle(
color: Colors.grey[600],
fontStyle: FontStyle.italic,
),
),
],
),
),
),
);
},
),
),
],
);
}
Widget _buildInfoRow(String label, String value) {
return Row(
children: [
Text('$label: ', style: const TextStyle(fontWeight: FontWeight.bold)),
Text(value),
],
);
}
String _formatDate(DateTime date) {
return '${date.year}${date.month}${date.day}';
}
String _formatPrice(double price) {
if (price == 0) {
return '0';
}
// 천 단위 콤마 추가
final priceInt = price.toInt();
return priceInt.toString().replaceAllMapped(
RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'),
(Match m) => '${m[1]},',
);
}
void _showCancelDialog(SubscriptionService subscriptionService) {
showDialog(
context: context,
builder: (context) => 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('예, 취소합니다'),
),
],
),
);
}
void _toggleAutoRenew(SubscriptionService subscriptionService) async {
final success = await subscriptionService.toggleAutoRenew();
if (success && mounted) {
final isAutoRenew = subscriptionService.currentSubscription!.autoRenew;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(isAutoRenew ? '자동 갱신이 활성화되었습니다' : '자동 갱신이 비활성화되었습니다'),
),
);
}
}
}