회원목록
This commit is contained in:
@@ -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('로그인'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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('회원가입'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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: () {
|
||||
// 이벤트 상세 화면으로 이동 (추후 구현)
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 ? '자동 갱신이 활성화되었습니다' : '자동 갱신이 비활성화되었습니다'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user