회원목록

This commit is contained in:
2025-08-01 16:45:26 +09:00
parent a996def67a
commit ed8c7eacba
191 changed files with 17491 additions and 0 deletions
+38
View File
@@ -0,0 +1,38 @@
class ApiConfig {
// 백엔드 API 기본 URL
static const String baseUrl = 'https://lanebow.com/api';
// API 엔드포인트
static const String login = '/auth/login';
static const String register = '/auth/register';
static const String forgotPassword = '/auth/forgot-password';
static const String profile = '/users/profile';
// 클럽 관련 엔드포인트
static const String clubs = '/club';
static const String members = '/members';
static const String events = '/events';
static const String scores = '/scores';
// 구독 관련 엔드포인트
static const String subscriptions = '/subscriptions';
static const String subscriptionPlans = '/subscriptions/plans';
static const String currentSubscription = '/subscriptions/current';
static const String subscribeClub = '/subscriptions/subscribe';
static const String subscriptionPayments = '/subscriptions/payments';
static const String changePlan = '/subscriptions/change-with-features';
// 결제 관련 엔드포인트
static const String paymentRequest = '/subscriptions/payment/request';
static const String paymentSuccess = '/subscriptions/payment/success';
static const String paymentFail = '/subscriptions/payment/fail';
// 인앱 결제 검증 엔드포인트
static const String verifyReceipt = '/subscriptions/verify-receipt';
// 토큰 저장 키
static const String tokenKey = 'auth_token';
static const String userIdKey = 'user_id';
static const String userRoleKey = 'user_role';
static const String clubIdKey = 'club_id';
}
+344
View File
@@ -0,0 +1,344 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'services/auth_service.dart';
import 'services/club_service.dart';
import 'services/member_service.dart';
import 'services/event_service.dart';
import 'services/score_service.dart';
import 'screens/auth/login_screen.dart';
import 'screens/auth/profile_screen.dart';
import 'screens/club/dashboard_screen.dart';
import 'screens/club/members_screen.dart';
import 'screens/club/events_screen.dart';
import 'screens/club/club_settings_screen.dart';
import 'screens/score/club_statistics_screen.dart';
// 전역 네비게이터 키 (인증 오류 처리용)
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
void main() async {
WidgetsFlutterBinding.ensureInitialized();
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => AuthService()),
ChangeNotifierProvider(create: (_) => ClubService()),
ChangeNotifierProvider(create: (_) => MemberService()),
ChangeNotifierProvider(create: (_) => EventService()),
ChangeNotifierProvider(create: (_) => ScoreService()),
],
child: Consumer<AuthService>(
builder: (context, authService, _) {
// 앱 시작 시 인증 서비스 초기화
Future.delayed(Duration.zero, () {
if (!authService.isInitialized) {
authService.initialize();
}
});
return MaterialApp(
navigatorKey: navigatorKey, // 인증 오류 처리를 위한 전역 네비게이터 키
title: '볼링 매니저',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
useMaterial3: true,
appBarTheme: const AppBarTheme(
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
),
),
// 로케일 설정 추가
localizationsDelegates: const [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [
Locale('ko', 'KR'),
Locale('en', 'US'),
],
locale: const Locale('ko', 'KR'),
home: authService.isAuthenticated ? const HomeScreen() : const LoginScreen(),
routes: {
'/login': (context) => const LoginScreen(),
'/profile': (context) => const ProfileScreen(),
ClubSettingsScreen.routeName: (context) => const ClubSettingsScreen(),
},
);
},
),
);
}
}
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
int _selectedIndex = 0;
bool _isInit = false;
static final List<Widget> _widgetOptions = <Widget>[
const DashboardScreen(),
const MembersScreen(),
const EventsScreen(),
const ClubStatisticsScreen(),
const ProfileScreen(),
];
void _onItemTapped(int index) {
setState(() {
_selectedIndex = index;
});
}
// 클럽 선택 다이얼로그 표시
Future<void> _showClubSelectionDialog(BuildContext context) async {
final clubService = Provider.of<ClubService>(context, listen: false);
final authService = Provider.of<AuthService>(context, listen: false);
// 사용자의 모든 클럽 가져오기
if (clubService.clubs.isEmpty) {
try {
await clubService.fetchUserClubs();
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('클럽 목록을 불러오는데 실패했습니다: $e')),
);
return;
}
}
if (clubService.clubs.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('소속된 클럽이 없습니다')),
);
return;
}
if (!context.mounted) return;
// 다이얼로그 표시
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('클럽 선택'),
content: SizedBox(
width: double.maxFinite,
child: ListView.builder(
shrinkWrap: true,
itemCount: clubService.clubs.length,
itemBuilder: (context, index) {
final club = clubService.clubs[index];
final isSelected = clubService.currentClub?.id == club.id;
return ListTile(
title: Text(club.name),
subtitle: Text(club.description ?? ''),
trailing: isSelected ? const Icon(Icons.check, color: Colors.blue) : null,
onTap: () async {
Navigator.of(context).pop();
if (!isSelected) {
try {
// 백엔드 세션에 clubId 저장하고 클럽 정보 가져오기
await clubService.selectClub(club.id);
// 관련 서비스 초기화
final memberService = Provider.of<MemberService>(context, listen: false);
final eventService = Provider.of<EventService>(context, listen: false);
final scoreService = Provider.of<ScoreService>(context, listen: false);
memberService.initialize(authService.token!);
eventService.initialize(authService.token!);
scoreService.initialize(authService.token!);
// 데이터 다시 로드
await Future.wait([
memberService.fetchClubMembers(),
eventService.fetchClubEvents(),
]);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${club.name} 클럽으로 변경되었습니다')),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('클럽 변경 중 오류가 발생했습니다: $e')),
);
}
}
}
},
);
},
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('취소'),
),
],
);
},
);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (!_isInit) {
_initializeServices();
_isInit = true;
}
}
Future<void> _initializeServices() async {
try {
final authService = Provider.of<AuthService>(context, listen: false);
final clubService = Provider.of<ClubService>(context, listen: false);
final scoreService = Provider.of<ScoreService>(context, listen: false);
if (authService.isAuthenticated && authService.token != null) {
// 클럽 서비스 초기화
await clubService.initialize(authService.token!);
// 클럽이 선택된 경우 점수 서비스 초기화
if (clubService.currentClub != null) {
scoreService.initialize(authService.token!);
} else {
// 현재 선택된 클럽이 없는 경우
// 사용자의 클럽 목록 가져오기
await clubService.fetchUserClubs();
// 클럽이 하나만 있으면 자동으로 선택
if (clubService.clubs.length == 1) {
try {
await clubService.selectClub(clubService.clubs[0].id);
scoreService.initialize(authService.token!);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${clubService.clubs[0].name} 클럽이 자동으로 선택되었습니다')),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('클럽 자동 선택 중 오류가 발생했습니다: $e')),
);
}
}
} else if (clubService.clubs.isNotEmpty) {
// 클럽이 여러 개 있으면 선택 다이얼로그 표시
Future.delayed(Duration.zero, () {
if (context.mounted) {
_showClubSelectionDialog(context);
}
});
} else {
// 클럽이 없는 경우 메시지 표시
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('소속된 클럽이 없습니다. 클럽을 생성하거나 초대를 요청하세요.')),
);
}
}
}
}
} catch (e) {
// 오류 처리
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('서비스 초기화 중 오류가 발생했습니다: $e')),
);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Consumer<ClubService>(
builder: (context, clubService, child) {
return InkWell(
onTap: () => _showClubSelectionDialog(context),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
clubService.currentClub?.name ?? '볼링 매니저',
style: const TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(width: 4),
const Icon(Icons.arrow_drop_down, size: 20),
],
),
);
},
),
actions: [
IconButton(
icon: const Icon(Icons.notifications),
onPressed: () {
// 알림 기능 (추후 구현)
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('알림 기능은 아직 개발 중입니다')),
);
},
),
],
),
body: _widgetOptions.elementAt(_selectedIndex),
bottomNavigationBar: BottomNavigationBar(
type: BottomNavigationBarType.fixed,
items: const <BottomNavigationBarItem>[
BottomNavigationBarItem(
icon: Icon(Icons.dashboard),
label: '대시보드',
),
BottomNavigationBarItem(
icon: Icon(Icons.people),
label: '회원',
),
BottomNavigationBarItem(
icon: Icon(Icons.event),
label: '이벤트',
),
BottomNavigationBarItem(
icon: Icon(Icons.score),
label: '통계',
),
BottomNavigationBarItem(
icon: Icon(Icons.person),
label: '프로필',
),
],
currentIndex: _selectedIndex,
selectedItemColor: Colors.blue,
onTap: _onItemTapped,
),
);
}
}
+265
View File
@@ -0,0 +1,265 @@
/// 사용자 역할 정의
enum UserRole {
member, // 일반 회원
manager, // 매니저 (일부 관리 권한)
admin, // 관리자 (대부분의 관리 권한)
owner // 소유자 (모든 권한)
}
/// 관리 권한 정의
class AdminPermission {
final bool canManageMembers; // 회원 관리 권한
final bool canManageEvents; // 이벤트 관리 권한
final bool canManageScores; // 점수 관리 권한
final bool canManageSettings; // 설정 관리 권한
final bool canManageSubscription; // 구독 관리 권한
final bool canManageRoles; // 역할 관리 권한
final bool canViewFinancials; // 재정 정보 조회 권한
final bool canExportData; // 데이터 내보내기 권한
const AdminPermission({
this.canManageMembers = false,
this.canManageEvents = false,
this.canManageScores = false,
this.canManageSettings = false,
this.canManageSubscription = false,
this.canManageRoles = false,
this.canViewFinancials = false,
this.canExportData = false,
});
/// 역할에 따른 기본 권한 생성
factory AdminPermission.fromRole(UserRole role) {
switch (role) {
case UserRole.member:
return const AdminPermission();
case UserRole.manager:
return const AdminPermission(
canManageMembers: true,
canManageEvents: true,
canManageScores: true,
canExportData: true,
);
case UserRole.admin:
return const AdminPermission(
canManageMembers: true,
canManageEvents: true,
canManageScores: true,
canManageSettings: true,
canViewFinancials: true,
canExportData: true,
);
case UserRole.owner:
return const AdminPermission(
canManageMembers: true,
canManageEvents: true,
canManageScores: true,
canManageSettings: true,
canManageSubscription: true,
canManageRoles: true,
canViewFinancials: true,
canExportData: true,
);
}
}
/// JSON에서 권한 객체 생성
factory AdminPermission.fromJson(Map<String, dynamic> json) {
return AdminPermission(
canManageMembers: json['canManageMembers'] ?? false,
canManageEvents: json['canManageEvents'] ?? false,
canManageScores: json['canManageScores'] ?? false,
canManageSettings: json['canManageSettings'] ?? false,
canManageSubscription: json['canManageSubscription'] ?? false,
canManageRoles: json['canManageRoles'] ?? false,
canViewFinancials: json['canViewFinancials'] ?? false,
canExportData: json['canExportData'] ?? false,
);
}
/// 권한 객체를 JSON으로 변환
Map<String, dynamic> toJson() {
return {
'canManageMembers': canManageMembers,
'canManageEvents': canManageEvents,
'canManageScores': canManageScores,
'canManageSettings': canManageSettings,
'canManageSubscription': canManageSubscription,
'canManageRoles': canManageRoles,
'canViewFinancials': canViewFinancials,
'canExportData': canExportData,
};
}
}
/// 클럽 회원 역할 정보
class MemberRole {
final String userId;
final String memberId;
final String clubId;
final UserRole role;
final AdminPermission permissions;
final DateTime assignedAt;
final String? assignedBy;
MemberRole({
required this.userId,
required this.memberId,
required this.clubId,
required this.role,
required this.permissions,
required this.assignedAt,
this.assignedBy,
});
/// JSON에서 회원 역할 객체 생성
factory MemberRole.fromJson(Map<String, dynamic> json) {
return MemberRole(
userId: json['userId'],
memberId: json['memberId'],
clubId: json['clubId'],
role: UserRole.values.firstWhere(
(e) => e.toString().split('.').last == json['role'],
orElse: () => UserRole.member,
),
permissions: AdminPermission.fromJson(json['permissions'] ?? {}),
assignedAt: DateTime.parse(json['assignedAt']),
assignedBy: json['assignedBy'],
);
}
/// 회원 역할 객체를 JSON으로 변환
Map<String, dynamic> toJson() {
return {
'userId': userId,
'memberId': memberId,
'clubId': clubId,
'role': role.toString().split('.').last,
'permissions': permissions.toJson(),
'assignedAt': assignedAt.toIso8601String(),
'assignedBy': assignedBy,
};
}
/// 역할 이름 (한글)
String get roleName {
switch (role) {
case UserRole.member:
return '일반 회원';
case UserRole.manager:
return '매니저';
case UserRole.admin:
return '관리자';
case UserRole.owner:
return '소유자';
}
}
}
/// 클럽 설정 모델
class ClubSettings {
final String clubId;
final String clubName;
final String? logoUrl;
final String? bannerUrl;
final String? description;
final String? contactEmail;
final String? contactPhone;
final String? address;
final String? website;
final Map<String, dynamic>? customFields;
final Map<String, dynamic>? notificationSettings;
final Map<String, dynamic>? privacySettings;
final DateTime createdAt;
final DateTime updatedAt;
ClubSettings({
required this.clubId,
required this.clubName,
this.logoUrl,
this.bannerUrl,
this.description,
this.contactEmail,
this.contactPhone,
this.address,
this.website,
this.customFields,
this.notificationSettings,
this.privacySettings,
required this.createdAt,
required this.updatedAt,
});
/// JSON에서 클럽 설정 객체 생성
factory ClubSettings.fromJson(Map<String, dynamic> json) {
return ClubSettings(
clubId: json['clubId'],
clubName: json['clubName'],
logoUrl: json['logoUrl'],
bannerUrl: json['bannerUrl'],
description: json['description'],
contactEmail: json['contactEmail'],
contactPhone: json['contactPhone'],
address: json['address'],
website: json['website'],
customFields: json['customFields'],
notificationSettings: json['notificationSettings'],
privacySettings: json['privacySettings'],
createdAt: DateTime.parse(json['createdAt']),
updatedAt: DateTime.parse(json['updatedAt']),
);
}
/// 클럽 설정 객체를 JSON으로 변환
Map<String, dynamic> toJson() {
return {
'clubId': clubId,
'clubName': clubName,
'logoUrl': logoUrl,
'bannerUrl': bannerUrl,
'description': description,
'contactEmail': contactEmail,
'contactPhone': contactPhone,
'address': address,
'website': website,
'customFields': customFields,
'notificationSettings': notificationSettings,
'privacySettings': privacySettings,
'createdAt': createdAt.toIso8601String(),
'updatedAt': updatedAt.toIso8601String(),
};
}
/// 복사본 생성 (일부 필드 업데이트)
ClubSettings copyWith({
String? clubName,
String? logoUrl,
String? bannerUrl,
String? description,
String? contactEmail,
String? contactPhone,
String? address,
String? website,
Map<String, dynamic>? customFields,
Map<String, dynamic>? notificationSettings,
Map<String, dynamic>? privacySettings,
}) {
return ClubSettings(
clubId: clubId,
clubName: clubName ?? this.clubName,
logoUrl: logoUrl ?? this.logoUrl,
bannerUrl: bannerUrl ?? this.bannerUrl,
description: description ?? this.description,
contactEmail: contactEmail ?? this.contactEmail,
contactPhone: contactPhone ?? this.contactPhone,
address: address ?? this.address,
website: website ?? this.website,
customFields: customFields ?? this.customFields,
notificationSettings: notificationSettings ?? this.notificationSettings,
privacySettings: privacySettings ?? this.privacySettings,
createdAt: createdAt,
updatedAt: DateTime.now(),
);
}
}
+77
View File
@@ -0,0 +1,77 @@
class Club {
final String id;
final String name;
final String? description;
final String? logo;
final String? address;
final String? location;
final String? phone;
final String? email;
final String? website;
final int? memberCount;
final String? ownerId;
final int? femaleHandicap;
final String? averageCalculationPeriod;
final DateTime? createdAt;
final DateTime? updatedAt;
Club({
required this.id,
required this.name,
this.description,
this.logo,
this.address,
this.location,
this.phone,
this.email,
this.website,
this.memberCount,
this.ownerId,
this.femaleHandicap,
this.averageCalculationPeriod,
this.createdAt,
this.updatedAt,
});
// JSON 데이터로부터 Club 객체 생성
factory Club.fromJson(Map<String, dynamic> json) {
return Club(
id: json['id'] != null ? json['id'].toString() : '',
name: json['name'] ?? '',
description: json['description'],
logo: json['logo'],
address: json['address'],
location: json['location'],
phone: json['phone'] ?? json['contact'], // contact 필드도 확인
email: json['email'],
website: json['website'],
memberCount: json['memberCount'],
ownerId: json['ownerId']?.toString(),
femaleHandicap: json['femaleHandicap'] != null ? int.tryParse(json['femaleHandicap'].toString()) : null,
averageCalculationPeriod: json['averageCalculationPeriod'],
createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt']) : null,
updatedAt: json['updatedAt'] != null ? DateTime.parse(json['updatedAt']) : null,
);
}
// Club 객체를 JSON으로 변환
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'description': description,
'logo': logo,
'address': address,
'location': location,
'phone': phone,
'email': email,
'website': website,
'memberCount': memberCount,
'ownerId': ownerId,
'femaleHandicap': femaleHandicap,
'averageCalculationPeriod': averageCalculationPeriod,
'createdAt': createdAt?.toIso8601String(),
'updatedAt': updatedAt?.toIso8601String(),
};
}
}
+73
View File
@@ -0,0 +1,73 @@
class Event {
final String id;
final String clubId;
final String title;
final String? description;
final DateTime startDate;
final DateTime? endDate;
final String? location;
final String? type;
final int? maxParticipants;
final int? currentParticipants;
final bool isActive;
final String? createdBy;
final DateTime? createdAt;
final DateTime? updatedAt;
Event({
required this.id,
required this.clubId,
required this.title,
this.description,
required this.startDate,
this.endDate,
this.location,
this.type,
this.maxParticipants,
this.currentParticipants,
required this.isActive,
this.createdBy,
this.createdAt,
this.updatedAt,
});
// JSON 데이터로부터 Event 객체 생성
factory Event.fromJson(Map<String, dynamic> json) {
return Event(
id: json['id']?.toString() ?? '',
clubId: json['clubId']?.toString() ?? '',
title: json['title'] ?? '',
description: json['description']?.toString(),
startDate: json['startDate'] != null ? DateTime.parse(json['startDate'].toString()) : DateTime.now(),
endDate: json['endDate'] != null ? DateTime.parse(json['endDate'].toString()) : null,
location: json['location']?.toString(),
type: json['type']?.toString(),
maxParticipants: json['maxParticipants'],
currentParticipants: json['currentParticipants'],
isActive: json['isActive'] ?? true,
createdBy: json['createdBy']?.toString(),
createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt'].toString()) : null,
updatedAt: json['updatedAt'] != null ? DateTime.parse(json['updatedAt'].toString()) : null,
);
}
// Event 객체를 JSON으로 변환
Map<String, dynamic> toJson() {
return {
'id': id,
'clubId': clubId,
'title': title,
'description': description,
'startDate': startDate.toIso8601String(),
'endDate': endDate?.toIso8601String(),
'location': location,
'type': type,
'maxParticipants': maxParticipants,
'currentParticipants': currentParticipants,
'isActive': isActive,
'createdBy': createdBy,
'createdAt': createdAt?.toIso8601String(),
'updatedAt': updatedAt?.toIso8601String(),
};
}
}
+126
View File
@@ -0,0 +1,126 @@
class Member {
final String id;
final String userId;
final String clubId;
final String name;
final String email;
final String? role;
final String? profileImage;
final String? phone;
final String? address;
final DateTime? joinDate;
final bool isActive;
final String? gender;
final String? memberType;
final int? handicap;
final String? status;
final DateTime? createdAt;
final DateTime? updatedAt;
Member({
required this.id,
required this.userId,
required this.clubId,
required this.name,
required this.email,
this.role,
this.profileImage,
this.phone,
this.address,
this.joinDate,
required this.isActive,
this.gender,
this.memberType,
this.handicap,
this.status,
this.createdAt,
this.updatedAt,
});
// JSON 데이터로부터 Member 객체 생성
factory Member.fromJson(Map<String, dynamic> json) {
return Member(
id: json['id']?.toString() ?? '',
userId: json['userId']?.toString() ?? '',
clubId: json['clubId']?.toString() ?? '',
name: json['name'] ?? '',
email: json['email'] ?? '',
role: json['role']?.toString(),
profileImage: json['profileImage']?.toString(),
phone: json['phone']?.toString(),
address: json['address']?.toString(),
joinDate: json['joinDate'] != null ? DateTime.parse(json['joinDate'].toString()) : null,
isActive: json['isActive'] ?? true,
gender: json['gender']?.toString(),
memberType: json['memberType']?.toString(),
handicap: json['handicap'] != null ? int.tryParse(json['handicap'].toString()) : null,
status: json['status']?.toString(),
createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt'].toString()) : null,
updatedAt: json['updatedAt'] != null ? DateTime.parse(json['updatedAt'].toString()) : null,
);
}
// Member 객체를 JSON으로 변환
Map<String, dynamic> toJson() {
return {
'id': id,
'userId': userId,
'clubId': clubId,
'name': name,
'email': email,
'role': role,
'profileImage': profileImage,
'phone': phone,
'address': address,
'joinDate': joinDate?.toIso8601String(),
'isActive': isActive,
'gender': gender,
'memberType': memberType,
'handicap': handicap,
'status': status,
'createdAt': createdAt?.toIso8601String(),
'updatedAt': updatedAt?.toIso8601String(),
};
}
// 객체 복사 및 특정 필드 업데이트를 위한 copyWith 메서드
Member copyWith({
String? id,
String? userId,
String? clubId,
String? name,
String? email,
String? role,
String? profileImage,
String? phone,
String? address,
DateTime? joinDate,
bool? isActive,
String? gender,
String? memberType,
int? handicap,
String? status,
DateTime? createdAt,
DateTime? updatedAt,
}) {
return Member(
id: id ?? this.id,
userId: userId ?? this.userId,
clubId: clubId ?? this.clubId,
name: name ?? this.name,
email: email ?? this.email,
role: role ?? this.role,
profileImage: profileImage ?? this.profileImage,
phone: phone ?? this.phone,
address: address ?? this.address,
joinDate: joinDate ?? this.joinDate,
isActive: isActive ?? this.isActive,
gender: gender ?? this.gender,
memberType: memberType ?? this.memberType,
handicap: handicap ?? this.handicap,
status: status ?? this.status,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
);
}
}
+83
View File
@@ -0,0 +1,83 @@
class Score {
final String id;
final String memberId;
final String eventId;
final String clubId;
final List<int> frames;
final int totalScore;
final DateTime date;
final String? notes;
Score({
required this.id,
required this.memberId,
required this.eventId,
required this.clubId,
required this.frames,
required this.totalScore,
required this.date,
this.notes,
});
factory Score.fromJson(Map<String, dynamic> json) {
return Score(
id: json['id']?.toString() ?? '',
memberId: json['memberId']?.toString() ?? '',
eventId: json['eventId']?.toString() ?? '',
clubId: json['clubId']?.toString() ?? '',
frames: json['frames'] != null ? List<int>.from(json['frames']) : [],
totalScore: json['totalScore'] ?? 0,
date: json['date'] != null ? DateTime.parse(json['date'].toString()) : DateTime.now(),
notes: json['notes']?.toString(),
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'memberId': memberId,
'eventId': eventId,
'clubId': clubId,
'frames': frames,
'totalScore': totalScore,
'date': date.toIso8601String(),
'notes': notes,
};
}
}
class ScoreStatistics {
final double average;
final int highScore;
final int lowScore;
final int gamesPlayed;
final Map<String, dynamic>? additionalStats;
ScoreStatistics({
required this.average,
required this.highScore,
required this.lowScore,
required this.gamesPlayed,
this.additionalStats,
});
factory ScoreStatistics.fromJson(Map<String, dynamic> json) {
return ScoreStatistics(
average: json['average'] != null ? (json['average'] is int ? (json['average'] as int).toDouble() : json['average'].toDouble()) : 0.0,
highScore: json['highScore'] ?? 0,
lowScore: json['lowScore'] ?? 0,
gamesPlayed: json['gamesPlayed'] ?? 0,
additionalStats: json['additionalStats'],
);
}
Map<String, dynamic> toJson() {
return {
'average': average,
'highScore': highScore,
'lowScore': lowScore,
'gamesPlayed': gamesPlayed,
'additionalStats': additionalStats,
};
}
}
+243
View File
@@ -0,0 +1,243 @@
/// 구독 플랜 타입
enum SubscriptionPlanType {
free, // 무료 플랜
basic, // 기본 유료 플랜
premium, // 프리미엄 플랜
enterprise // 기업용 플랜
}
/// 구독 상태
enum SubscriptionStatus {
active, // 활성화 상태
canceled, // 취소됨 (아직 만료 안됨)
expired, // 만료됨
pending, // 결제 대기 중
failed // 결제 실패
}
/// 구독 모델
class Subscription {
final String id;
final String userId;
final String? clubId;
final SubscriptionPlanType planType;
final SubscriptionStatus status;
final DateTime startDate;
final DateTime endDate;
final double price;
final String currency;
final bool autoRenew;
final Map<String, dynamic>? features;
final String? paymentMethod;
final String? transactionId;
final DateTime createdAt;
final DateTime updatedAt;
Subscription({
required this.id,
required this.userId,
this.clubId,
required this.planType,
required this.status,
required this.startDate,
required this.endDate,
required this.price,
required this.currency,
required this.autoRenew,
this.features,
this.paymentMethod,
this.transactionId,
required this.createdAt,
required this.updatedAt,
});
/// JSON에서 구독 객체 생성
factory Subscription.fromJson(Map<String, dynamic> json) {
return Subscription(
id: json['id'],
userId: json['userId'],
clubId: json['clubId'],
planType: SubscriptionPlanType.values.firstWhere(
(e) => e.toString().split('.').last == json['planType'],
orElse: () => SubscriptionPlanType.free,
),
status: SubscriptionStatus.values.firstWhere(
(e) => e.toString().split('.').last == json['status'],
orElse: () => SubscriptionStatus.expired,
),
startDate: DateTime.parse(json['startDate']),
endDate: DateTime.parse(json['endDate']),
price: json['price'].toDouble(),
currency: json['currency'],
autoRenew: json['autoRenew'] ?? false,
features: json['features'],
paymentMethod: json['paymentMethod'],
transactionId: json['transactionId'],
createdAt: DateTime.parse(json['createdAt']),
updatedAt: DateTime.parse(json['updatedAt']),
);
}
/// 구독 객체를 JSON으로 변환
Map<String, dynamic> toJson() {
return {
'id': id,
'userId': userId,
'clubId': clubId,
'planType': planType.toString().split('.').last,
'status': status.toString().split('.').last,
'startDate': startDate.toIso8601String(),
'endDate': endDate.toIso8601String(),
'price': price,
'currency': currency,
'autoRenew': autoRenew,
'features': features,
'paymentMethod': paymentMethod,
'transactionId': transactionId,
'createdAt': createdAt.toIso8601String(),
'updatedAt': updatedAt.toIso8601String(),
};
}
/// 구독이 활성 상태인지 확인
bool get isActive => status == SubscriptionStatus.active;
/// 구독이 만료되었는지 확인
bool get isExpired => status == SubscriptionStatus.expired ||
DateTime.now().isAfter(endDate);
/// 구독 남은 일수 계산
int get daysLeft {
if (isExpired) return 0;
return endDate.difference(DateTime.now()).inDays;
}
/// 구독 플랜 이름 (한글)
String get planName {
switch (planType) {
case SubscriptionPlanType.free:
return '무료';
case SubscriptionPlanType.basic:
return '기본';
case SubscriptionPlanType.premium:
return '프리미엄';
case SubscriptionPlanType.enterprise:
return '기업용';
}
}
}
/// 구독 플랜 정보 모델
class SubscriptionPlan {
final SubscriptionPlanType type;
final String name;
final String description;
final double monthlyPrice;
final double yearlyPrice;
final String currency;
final List<String> features;
final int maxMembers;
final int maxEvents;
final bool hasAdvancedStats;
final bool hasCustomization;
final bool hasPriority;
SubscriptionPlan({
required this.type,
required this.name,
required this.description,
required this.monthlyPrice,
required this.yearlyPrice,
required this.currency,
required this.features,
required this.maxMembers,
required this.maxEvents,
required this.hasAdvancedStats,
required this.hasCustomization,
required this.hasPriority,
});
/// 기본 구독 플랜 목록 반환
static List<SubscriptionPlan> getDefaultPlans() {
return [
SubscriptionPlan(
type: SubscriptionPlanType.free,
name: '무료',
description: '소규모 클럽을 위한 기본 기능',
monthlyPrice: 0,
yearlyPrice: 0,
currency: 'KRW',
features: [
'최대 10명 회원 관리',
'기본 점수 기록',
'간단한 통계',
],
maxMembers: 10,
maxEvents: 5,
hasAdvancedStats: false,
hasCustomization: false,
hasPriority: false,
),
SubscriptionPlan(
type: SubscriptionPlanType.basic,
name: '기본',
description: '중소규모 클럽을 위한 확장 기능',
monthlyPrice: 9900,
yearlyPrice: 99000,
currency: 'KRW',
features: [
'최대 30명 회원 관리',
'상세 점수 분석',
'이벤트 관리',
'회원 통계',
],
maxMembers: 30,
maxEvents: 20,
hasAdvancedStats: true,
hasCustomization: false,
hasPriority: false,
),
SubscriptionPlan(
type: SubscriptionPlanType.premium,
name: '프리미엄',
description: '대규모 클럽을 위한 고급 기능',
monthlyPrice: 19900,
yearlyPrice: 199000,
currency: 'KRW',
features: [
'무제한 회원 관리',
'고급 통계 및 분석',
'무제한 이벤트',
'맞춤형 보고서',
'우선 지원',
],
maxMembers: 100,
maxEvents: 100,
hasAdvancedStats: true,
hasCustomization: true,
hasPriority: true,
),
SubscriptionPlan(
type: SubscriptionPlanType.enterprise,
name: '기업용',
description: '프로 클럽 및 단체를 위한 맞춤형 솔루션',
monthlyPrice: 49900,
yearlyPrice: 499000,
currency: 'KRW',
features: [
'무제한 회원 및 이벤트',
'전문가 수준 분석',
'맞춤형 기능',
'전담 지원',
'API 액세스',
],
maxMembers: 1000,
maxEvents: 1000,
hasAdvancedStats: true,
hasCustomization: true,
hasPriority: true,
),
];
}
}
+45
View File
@@ -0,0 +1,45 @@
class User {
final String id;
final String email;
final String name;
final String role;
final String? profileImage;
final String? clubId;
final String? clubRole;
User({
required this.id,
required this.email,
required this.name,
required this.role,
this.profileImage,
this.clubId,
this.clubRole,
});
// JSON 데이터로부터 User 객체 생성
factory User.fromJson(Map<String, dynamic> json) {
return User(
id: json['id'] != null ? json['id'].toString() : '',
email: json['email'] ?? '',
name: json['name'] ?? '',
role: json['role'] ?? 'user',
profileImage: json['profileImage'],
clubId: json['clubId']?.toString(),
clubRole: json['clubRole'],
);
}
// User 객체를 JSON으로 변환
Map<String, dynamic> toJson() {
return {
'id': id,
'email': email,
'name': name,
'role': role,
'profileImage': profileImage,
'clubId': clubId,
'clubRole': clubRole,
};
}
}
@@ -0,0 +1,456 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../services/admin_service.dart';
import '../../services/subscription_service.dart';
import '../../models/subscription_model.dart';
/// 관리자 대시보드 화면
class AdminDashboardScreen extends StatefulWidget {
const AdminDashboardScreen({super.key});
@override
State<AdminDashboardScreen> createState() => _AdminDashboardScreenState();
}
class _AdminDashboardScreenState extends State<AdminDashboardScreen> {
Map<String, dynamic>? _analyticsData;
bool _isLoading = false;
@override
void initState() {
super.initState();
_loadData();
}
/// 데이터 로드
Future<void> _loadData() async {
setState(() {
_isLoading = true;
});
try {
final adminService = Provider.of<AdminService>(context, listen: false);
final analyticsData = await adminService.fetchClubAnalytics();
setState(() {
_analyticsData = analyticsData;
});
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('데이터 로드 중 오류가 발생했습니다: $e')),
);
} finally {
setState(() {
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
final adminService = Provider.of<AdminService>(context);
final subscriptionService = Provider.of<SubscriptionService>(context);
return Scaffold(
appBar: AppBar(
title: const Text('관리자 대시보드'),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _loadData,
),
],
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: RefreshIndicator(
onRefresh: _loadData,
child: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildPermissionsCard(adminService),
const SizedBox(height: 16),
_buildSubscriptionCard(subscriptionService),
const SizedBox(height: 16),
_buildAnalyticsCard(),
const SizedBox(height: 16),
_buildQuickActionsCard(context, adminService),
],
),
),
),
);
}
/// 권한 정보 카드
Widget _buildPermissionsCard(AdminService adminService) {
return Card(
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.admin_panel_settings),
const SizedBox(width: 8),
Text(
'관리자 권한',
style: Theme.of(context).textTheme.titleLarge,
),
],
),
const Divider(),
const SizedBox(height: 8),
_buildPermissionItem(
'회원 관리',
adminService.canManageMembers,
Icons.people,
),
_buildPermissionItem(
'이벤트 관리',
adminService.canManageEvents,
Icons.event,
),
_buildPermissionItem(
'점수 관리',
adminService.canManageScores,
Icons.score,
),
_buildPermissionItem(
'설정 관리',
adminService.canManageSettings,
Icons.settings,
),
_buildPermissionItem(
'구독 관리',
adminService.canManageSubscription,
Icons.subscriptions,
),
_buildPermissionItem(
'역할 관리',
adminService.canManageRoles,
Icons.admin_panel_settings,
),
_buildPermissionItem(
'재무 정보 조회',
adminService.canViewFinancials,
Icons.attach_money,
),
_buildPermissionItem(
'데이터 내보내기',
adminService.canExportData,
Icons.file_download,
),
],
),
),
);
}
/// 권한 항목 위젯
Widget _buildPermissionItem(String title, bool hasPermission, IconData icon) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Row(
children: [
Icon(
icon,
size: 18,
color: hasPermission ? Colors.green : Colors.grey,
),
const SizedBox(width: 8),
Text(title),
const Spacer(),
Icon(
hasPermission ? Icons.check_circle : Icons.cancel,
color: hasPermission ? Colors.green : Colors.red,
size: 18,
),
],
),
);
}
/// 구독 정보 카드
Widget _buildSubscriptionCard(SubscriptionService subscriptionService) {
final subscription = subscriptionService.currentSubscription;
return Card(
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.subscriptions),
const SizedBox(width: 8),
Text(
'구독 정보',
style: Theme.of(context).textTheme.titleLarge,
),
],
),
const Divider(),
const SizedBox(height: 8),
if (subscription != null) ...[
_buildInfoRow('플랜', subscription.planName),
_buildInfoRow('상태', _getStatusText(subscription.status)),
_buildInfoRow('시작일', _formatDate(subscription.startDate)),
_buildInfoRow('만료일', _formatDate(subscription.endDate)),
_buildInfoRow('자동 갱신', subscription.autoRenew ? '활성화' : '비활성화'),
_buildInfoRow('남은 일수', '${subscription.daysLeft}'),
] else
const Text('활성화된 구독이 없습니다.'),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
Navigator.of(context).pushNamed('/subscription/manage');
},
child: const Text('구독 관리'),
),
],
),
),
);
}
/// 분석 데이터 카드
Widget _buildAnalyticsCard() {
return Card(
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.analytics),
const SizedBox(width: 8),
Text(
'클럽 통계',
style: Theme.of(context).textTheme.titleLarge,
),
],
),
const Divider(),
const SizedBox(height: 8),
if (_analyticsData != null) ...[
_buildInfoRow('총 회원 수', '${_analyticsData!['totalMembers'] ?? 0}'),
_buildInfoRow('활성 회원 수', '${_analyticsData!['activeMembers'] ?? 0}'),
_buildInfoRow('이번 달 신규 회원', '${_analyticsData!['newMembersThisMonth'] ?? 0}'),
_buildInfoRow('총 이벤트 수', '${_analyticsData!['totalEvents'] ?? 0}'),
_buildInfoRow('이번 달 이벤트', '${_analyticsData!['eventsThisMonth'] ?? 0}'),
_buildInfoRow('총 게임 수', '${_analyticsData!['totalGames'] ?? 0}게임'),
_buildInfoRow('평균 점수', '${_analyticsData!['averageScore'] ?? 0}'),
] else
const Text('통계 데이터를 불러올 수 없습니다.'),
],
),
),
);
}
/// 빠른 작업 카드
Widget _buildQuickActionsCard(BuildContext context, AdminService adminService) {
return Card(
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.flash_on),
const SizedBox(width: 8),
Text(
'빠른 작업',
style: Theme.of(context).textTheme.titleLarge,
),
],
),
const Divider(),
const SizedBox(height: 8),
Wrap(
spacing: 8.0,
runSpacing: 8.0,
children: [
if (adminService.canManageMembers)
_buildActionButton(
context,
'회원 관리',
Icons.people,
() => Navigator.of(context).pushNamed('/admin/members'),
),
if (adminService.canManageEvents)
_buildActionButton(
context,
'이벤트 관리',
Icons.event,
() => Navigator.of(context).pushNamed('/admin/events'),
),
if (adminService.canManageSettings)
_buildActionButton(
context,
'클럽 설정',
Icons.settings,
() => Navigator.of(context).pushNamed('/admin/settings'),
),
if (adminService.canManageRoles)
_buildActionButton(
context,
'역할 관리',
Icons.admin_panel_settings,
() => Navigator.of(context).pushNamed('/admin/roles'),
),
if (adminService.canExportData)
_buildActionButton(
context,
'데이터 내보내기',
Icons.file_download,
() => _showExportDialog(context),
),
],
),
],
),
),
);
}
/// 작업 버튼 위젯
Widget _buildActionButton(
BuildContext context,
String label,
IconData icon,
VoidCallback onPressed,
) {
return ElevatedButton.icon(
onPressed: onPressed,
icon: Icon(icon, size: 18),
label: Text(label),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
);
}
/// 데이터 내보내기 다이얼로그
void _showExportDialog(BuildContext context) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('데이터 내보내기'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.people),
title: const Text('회원 데이터'),
onTap: () => _exportData(context, 'members'),
),
ListTile(
leading: const Icon(Icons.event),
title: const Text('이벤트 데이터'),
onTap: () => _exportData(context, 'events'),
),
ListTile(
leading: const Icon(Icons.score),
title: const Text('점수 데이터'),
onTap: () => _exportData(context, 'scores'),
),
ListTile(
leading: const Icon(Icons.analytics),
title: const Text('통계 데이터'),
onTap: () => _exportData(context, 'analytics'),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('취소'),
),
],
),
);
}
/// 데이터 내보내기 처리
Future<void> _exportData(BuildContext context, String dataType) async {
Navigator.of(context).pop(); // 다이얼로그 닫기
setState(() {
_isLoading = true;
});
try {
final adminService = Provider.of<AdminService>(context, listen: false);
final downloadUrl = await adminService.exportClubData(dataType);
if (downloadUrl != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('데이터 내보내기 성공: $downloadUrl')),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('데이터 내보내기에 실패했습니다.')),
);
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('데이터 내보내기 중 오류가 발생했습니다: $e')),
);
} finally {
setState(() {
_isLoading = false;
});
}
}
/// 정보 행 위젯
Widget _buildInfoRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'$label:',
style: const TextStyle(fontWeight: FontWeight.bold),
),
Text(value),
],
),
);
}
/// 날짜 포맷팅
String _formatDate(DateTime date) {
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
}
/// 구독 상태 텍스트
String _getStatusText(SubscriptionStatus status) {
switch (status) {
case SubscriptionStatus.active:
return '활성화';
case SubscriptionStatus.canceled:
return '취소됨';
case SubscriptionStatus.expired:
return '만료됨';
case SubscriptionStatus.pending:
return '대기 중';
case SubscriptionStatus.failed:
return '실패';
}
}
}
@@ -0,0 +1,168 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../services/auth_service.dart';
class ForgotPasswordScreen extends StatefulWidget {
const ForgotPasswordScreen({super.key});
@override
State<ForgotPasswordScreen> createState() => _ForgotPasswordScreenState();
}
class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
bool _isSubmitting = false;
bool _emailSent = false;
@override
void dispose() {
_emailController.dispose();
super.dispose();
}
// 비밀번호 재설정 이메일 전송 함수
Future<void> _resetPassword() async {
if (_formKey.currentState!.validate()) {
setState(() {
_isSubmitting = true;
});
final authService = Provider.of<AuthService>(context, listen: false);
final success = await authService.sendPasswordResetEmail(
_emailController.text.trim(),
);
setState(() {
_isSubmitting = false;
_emailSent = success;
});
if (success && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('비밀번호 재설정 이메일이 전송되었습니다. 이메일을 확인해주세요.'),
backgroundColor: Colors.green,
),
);
} else if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('비밀번호 재설정 이메일 전송에 실패했습니다. 이메일 주소를 확인해주세요.'),
backgroundColor: Colors.red,
),
);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('비밀번호 찾기'),
),
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Form(
key: _formKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Icon(
Icons.lock_reset,
size: 80,
color: Colors.blue,
),
const SizedBox(height: 24),
const Text(
'비밀번호를 잊으셨나요?',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
const Text(
'가입하신 이메일 주소를 입력하시면 비밀번호 재설정 링크를 보내드립니다.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16,
color: Colors.grey,
),
),
const SizedBox(height: 32),
// 이메일 입력 필드
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: '이메일',
prefixIcon: Icon(Icons.email),
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.isEmpty) {
return '이메일을 입력해주세요';
}
if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(value)) {
return '유효한 이메일 주소를 입력해주세요';
}
return null;
},
),
const SizedBox(height: 24),
// 비밀번호 재설정 이메일 전송 버튼
ElevatedButton(
onPressed: _isSubmitting ? null : _resetPassword,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
),
child: _isSubmitting
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text(
'비밀번호 재설정 이메일 전송',
style: TextStyle(fontSize: 16),
),
),
const SizedBox(height: 16),
// 로그인 화면으로 돌아가기
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('비밀번호가 기억나셨나요?'),
TextButton(
onPressed: () {
Navigator.pop(context); // 로그인 화면으로 돌아가기
},
child: const Text('로그인'),
),
],
),
],
),
),
),
),
),
);
}
}
+212
View File
@@ -0,0 +1,212 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../services/auth_service.dart';
import 'register_screen.dart';
import 'forgot_password_screen.dart';
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final _formKey = GlobalKey<FormState>();
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
bool _isPasswordVisible = false;
bool _rememberMe = false;
@override
void dispose() {
_usernameController.dispose();
_passwordController.dispose();
super.dispose();
}
// 로그인 처리 함수
Future<void> _login() async {
if (_formKey.currentState!.validate()) {
final authService = Provider.of<AuthService>(context, listen: false);
final success = await authService.login(
_usernameController.text.trim(),
_passwordController.text,
);
if (success && mounted) {
// 로그인 성공 시 홈 화면으로 이동
Navigator.of(context).pushNamedAndRemoveUntil('/', (route) => false);
} else if (!success && mounted) {
// 로그인 실패 시 스낵바 표시
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('로그인에 실패했습니다. 아이디와 비밀번호를 확인해주세요.'),
backgroundColor: Colors.red,
),
);
}
}
}
@override
Widget build(BuildContext context) {
final authService = Provider.of<AuthService>(context);
return Scaffold(
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Form(
key: _formKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 로고 이미지
Column(
children: [
Image.asset(
'assets/images/logo.png',
width: 120,
height: 120,
),
const SizedBox(height: 16),
const Text(
'레인보우 - 볼링 클럽 매니저',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.blue,
),
),
],
),
const SizedBox(height: 48),
// 사용자명(아이디) 입력 필드
TextFormField(
controller: _usernameController,
decoration: const InputDecoration(
labelText: '아이디',
prefixIcon: Icon(Icons.person),
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.isEmpty) {
return '아이디를 입력해주세요';
}
return null;
},
),
const SizedBox(height: 16),
// 비밀번호 입력 필드
TextFormField(
controller: _passwordController,
obscureText: !_isPasswordVisible,
decoration: InputDecoration(
labelText: '비밀번호',
prefixIcon: const Icon(Icons.lock),
border: const OutlineInputBorder(),
suffixIcon: IconButton(
icon: Icon(
_isPasswordVisible ? Icons.visibility : Icons.visibility_off,
),
onPressed: () {
setState(() {
_isPasswordVisible = !_isPasswordVisible;
});
},
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return '비밀번호를 입력해주세요';
}
return null;
},
),
const SizedBox(height: 8),
// 로그인 상태 유지 체크박스
Row(
children: [
Checkbox(
value: _rememberMe,
onChanged: (value) {
setState(() {
_rememberMe = value ?? false;
});
},
),
const Text('로그인 상태 유지'),
const Spacer(),
TextButton(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const ForgotPasswordScreen(),
),
);
},
child: const Text('비밀번호 찾기'),
),
],
),
const SizedBox(height: 24),
// 로그인 버튼
ElevatedButton(
onPressed: authService.isLoading ? null : _login,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
),
child: authService.isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text(
'로그인',
style: TextStyle(fontSize: 16),
),
),
const SizedBox(height: 16),
// 회원가입 링크
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('계정이 없으신가요?'),
TextButton(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const RegisterScreen(),
),
);
},
child: const Text('회원가입'),
),
],
),
],
),
),
),
),
),
);
}
}
+186
View File
@@ -0,0 +1,186 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../services/auth_service.dart';
import '../../models/user_model.dart';
class ProfileScreen extends StatefulWidget {
const ProfileScreen({super.key});
@override
State<ProfileScreen> createState() => _ProfileScreenState();
}
class _ProfileScreenState extends State<ProfileScreen> {
final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController();
bool _isEditing = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
final user = Provider.of<AuthService>(context, listen: false).currentUser;
if (user != null) {
_nameController.text = user.name;
}
});
}
@override
void dispose() {
_nameController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('내 프로필'),
actions: [
IconButton(
icon: Icon(_isEditing ? Icons.save : Icons.edit),
onPressed: () {
setState(() {
_isEditing = !_isEditing;
});
if (!_isEditing) {
// 저장 로직 구현 (추후 개발)
if (_formKey.currentState!.validate()) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('프로필이 업데이트되었습니다')),
);
}
}
},
),
],
),
body: Consumer<AuthService>(
builder: (context, authService, child) {
final User? user = authService.currentUser;
if (user == null) {
return const Center(child: Text('사용자 정보를 불러올 수 없습니다'));
}
return SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// 프로필 이미지
CircleAvatar(
radius: 50,
backgroundImage: user.profileImage != null
? NetworkImage(user.profileImage!)
: null,
child: user.profileImage == null
? Text(
user.name.isNotEmpty ? user.name[0].toUpperCase() : '?',
style: const TextStyle(fontSize: 40),
)
: null,
),
const SizedBox(height: 16),
// 이름 필드
TextFormField(
controller: _nameController,
enabled: _isEditing,
decoration: const InputDecoration(
labelText: '이름',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.isEmpty) {
return '이름을 입력해주세요';
}
return null;
},
),
const SizedBox(height: 16),
// 이메일 (수정 불가)
TextFormField(
initialValue: user.email,
enabled: false,
decoration: const InputDecoration(
labelText: '이메일',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
// 역할 표시
TextFormField(
initialValue: _getRoleDisplayName(user.role),
enabled: false,
decoration: const InputDecoration(
labelText: '역할',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
// 클럽 역할 표시 (클럽이 있는 경우)
if (user.clubRole != null)
TextFormField(
initialValue: _getRoleDisplayName(user.clubRole!),
enabled: false,
decoration: const InputDecoration(
labelText: '클럽 내 역할',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 32),
// 로그아웃 버튼
ElevatedButton(
onPressed: () async {
await authService.logout();
if (context.mounted) {
Navigator.of(context).pushReplacementNamed('/login');
}
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: 32,
vertical: 12,
),
),
child: const Text('로그아웃'),
),
],
),
),
);
},
),
);
}
// 역할 표시 이름 변환
String _getRoleDisplayName(String role) {
switch (role) {
case 'admin':
return '관리자';
case 'superadmin':
return '최고 관리자';
case 'user':
return '일반 사용자';
case 'owner':
return '클럽 소유자';
case 'manager':
return '클럽 매니저';
case 'member':
return '클럽 회원';
default:
return role;
}
}
}
@@ -0,0 +1,222 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../services/auth_service.dart';
class RegisterScreen extends StatefulWidget {
const RegisterScreen({super.key});
@override
State<RegisterScreen> createState() => _RegisterScreenState();
}
class _RegisterScreenState extends State<RegisterScreen> {
final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
final _confirmPasswordController = TextEditingController();
bool _isPasswordVisible = false;
bool _isConfirmPasswordVisible = false;
@override
void dispose() {
_nameController.dispose();
_emailController.dispose();
_passwordController.dispose();
_confirmPasswordController.dispose();
super.dispose();
}
// 회원가입 처리 함수
Future<void> _register() async {
if (_formKey.currentState!.validate()) {
final authService = Provider.of<AuthService>(context, listen: false);
final success = await authService.register(
_nameController.text.trim(),
_emailController.text.trim(),
_passwordController.text,
);
if (success && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('회원가입이 완료되었습니다. 로그인해주세요.'),
backgroundColor: Colors.green,
),
);
Navigator.pop(context); // 로그인 화면으로 돌아가기
} else if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('회원가입에 실패했습니다. 다시 시도해주세요.'),
backgroundColor: Colors.red,
),
);
}
}
}
@override
Widget build(BuildContext context) {
final authService = Provider.of<AuthService>(context);
return Scaffold(
appBar: AppBar(
title: const Text('회원가입'),
),
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Form(
key: _formKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 이름 입력 필드
TextFormField(
controller: _nameController,
decoration: const InputDecoration(
labelText: '이름',
prefixIcon: Icon(Icons.person),
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.isEmpty) {
return '이름을 입력해주세요';
}
return null;
},
),
const SizedBox(height: 16),
// 이메일 입력 필드
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: '이메일',
prefixIcon: Icon(Icons.email),
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.isEmpty) {
return '이메일을 입력해주세요';
}
if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(value)) {
return '유효한 이메일 주소를 입력해주세요';
}
return null;
},
),
const SizedBox(height: 16),
// 비밀번호 입력 필드
TextFormField(
controller: _passwordController,
obscureText: !_isPasswordVisible,
decoration: InputDecoration(
labelText: '비밀번호',
prefixIcon: const Icon(Icons.lock),
border: const OutlineInputBorder(),
suffixIcon: IconButton(
icon: Icon(
_isPasswordVisible ? Icons.visibility : Icons.visibility_off,
),
onPressed: () {
setState(() {
_isPasswordVisible = !_isPasswordVisible;
});
},
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return '비밀번호를 입력해주세요';
}
if (value.length < 6) {
return '비밀번호는 6자 이상이어야 합니다';
}
return null;
},
),
const SizedBox(height: 16),
// 비밀번호 확인 입력 필드
TextFormField(
controller: _confirmPasswordController,
obscureText: !_isConfirmPasswordVisible,
decoration: InputDecoration(
labelText: '비밀번호 확인',
prefixIcon: const Icon(Icons.lock_outline),
border: const OutlineInputBorder(),
suffixIcon: IconButton(
icon: Icon(
_isConfirmPasswordVisible ? Icons.visibility : Icons.visibility_off,
),
onPressed: () {
setState(() {
_isConfirmPasswordVisible = !_isConfirmPasswordVisible;
});
},
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return '비밀번호를 다시 입력해주세요';
}
if (value != _passwordController.text) {
return '비밀번호가 일치하지 않습니다';
}
return null;
},
),
const SizedBox(height: 24),
// 회원가입 버튼
ElevatedButton(
onPressed: authService.isLoading ? null : _register,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
),
child: authService.isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text(
'회원가입',
style: TextStyle(fontSize: 16),
),
),
const SizedBox(height: 16),
// 로그인 화면으로 돌아가기
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('이미 계정이 있으신가요?'),
TextButton(
onPressed: () {
Navigator.pop(context); // 로그인 화면으로 돌아가기
},
child: const Text('로그인'),
),
],
),
],
),
),
),
),
),
);
}
}
@@ -0,0 +1,367 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../models/member_model.dart';
import '../../services/auth_service.dart';
import '../../services/club_service.dart';
import '../../services/member_service.dart';
import '../../utils/enum_mappings.dart';
class ClubSettingsScreen extends StatefulWidget {
static const routeName = '/club-settings';
const ClubSettingsScreen({super.key});
@override
_ClubSettingsScreenState createState() => _ClubSettingsScreenState();
}
class _ClubSettingsScreenState extends State<ClubSettingsScreen> {
final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController();
final _descriptionController = TextEditingController();
final _locationController = TextEditingController();
final _femaleHandicapController = TextEditingController();
String? _selectedAverageCalculationPeriod;
String? _selectedOwnerId;
List<Member> _members = [];
bool _isLoading = true;
bool _hasEditPermission = false;
@override
void initState() {
super.initState();
_loadData();
}
@override
void dispose() {
_nameController.dispose();
_descriptionController.dispose();
_locationController.dispose();
_femaleHandicapController.dispose();
super.dispose();
}
Future<void> _loadData() async {
setState(() {
_isLoading = true;
});
try {
// 클럽 정보 로드
final clubService = Provider.of<ClubService>(context, listen: false);
final club = clubService.currentClub;
if (club != null) {
_nameController.text = club.name;
_descriptionController.text = club.description ?? '';
_locationController.text = club.location ?? '';
_femaleHandicapController.text = club.femaleHandicap?.toString() ?? '';
_selectedAverageCalculationPeriod =
club.averageCalculationPeriod ?? 'total';
_selectedOwnerId = club.ownerId;
}
// 회원 목록 로드
final memberService = Provider.of<MemberService>(context, listen: false);
await memberService.fetchClubMembers();
setState(() {
_members = memberService.members;
});
// 권한 확인
final authService = Provider.of<AuthService>(context, listen: false);
final currentUserId = authService.currentUser?.id;
// 현재 사용자가 클럽 소유자이거나 관리자인지 확인
if (currentUserId != null) {
final currentMember = _members.firstWhere(
(member) => member.userId == currentUserId,
orElse: () => Member(
id: '',
userId: '',
name: '',
memberType: '',
clubId: club?.id ?? '',
email: '',
isActive: true,
),
);
setState(() {
_hasEditPermission =
club?.ownerId == currentUserId || // 클럽 소유자
currentMember.memberType == 'manager' || // 클럽 매니저
authService.currentUser?.role == 'admin'; // 시스템 관리자
});
}
} catch (error) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('데이터 로드 중 오류가 발생했습니다: $error')));
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
Future<void> _saveClubInfo() async {
if (!_formKey.currentState!.validate()) {
return;
}
setState(() {
_isLoading = true;
});
try {
final clubService = Provider.of<ClubService>(context, listen: false);
final club = clubService.currentClub;
if (club != null) {
// 기본 업데이트 데이터 준비
final Map<String, dynamic> updates = {
'clubId': club.id,
'name': _nameController.text.trim(),
};
// 빈 문자열이 아닌 경우에만 추가 (null 값 명시적 처리)
if (_descriptionController.text.trim().isNotEmpty) {
updates['description'] = _descriptionController.text.trim();
} else if (_descriptionController.text.trim().isEmpty &&
club.description != null) {
// 기존에 값이 있었는데 지금 비어있으면 명시적으로 null 설정
updates['description'] = null;
}
if (_locationController.text.trim().isNotEmpty) {
updates['location'] = _locationController.text.trim();
} else if (_locationController.text.trim().isEmpty &&
club.location != null) {
updates['location'] = null;
}
if (_femaleHandicapController.text.trim().isNotEmpty) {
updates['femaleHandicap'] = int.parse(
_femaleHandicapController.text.trim(),
);
}
if (_selectedAverageCalculationPeriod != null) {
updates['averageCalculationPeriod'] =
_selectedAverageCalculationPeriod;
}
if (_selectedOwnerId != null) {
updates['ownerId'] = _selectedOwnerId;
}
// 모임장 변경 시에만 이메일 정보 추가
if (_selectedOwnerId != club.ownerId) {
// 모임장이 변경된 경우 해당 회원의 이메일 정보 찾기
final selectedMember = _members.firstWhere(
(member) => member.userId == _selectedOwnerId,
orElse: () => Member(
id: '',
userId: '',
name: '',
memberType: '',
clubId: club.id,
email: '',
isActive: true,
),
);
// 이메일이 있는 경우에만 추가 (빈 문자열이나 null은 전송하지 않음)
if (selectedMember.email.isNotEmpty) {
updates['ownerEmail'] = selectedMember.email;
}
}
await clubService.updateClub(club.id, updates);
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('클럽 정보가 성공적으로 저장되었습니다')));
}
}
} catch (error) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('클럽 정보 저장 중 오류가 발생했습니다: $error')),
);
}
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('클럽 설정')),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: Column(
children: [
if (!_hasEditPermission)
Container(
width: double.infinity,
padding: const EdgeInsets.all(16.0),
color: Colors.amber.shade100,
child: const Text(
'권한 알림: 클럽 설정을 변경하려면 클럽 소유자 또는 관리자 권한이 필요합니다.',
style: TextStyle(fontWeight: FontWeight.bold),
),
),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 클럽 이름
TextFormField(
controller: _nameController,
decoration: const InputDecoration(
labelText: '클럽 이름',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return '클럽 이름을 입력해주세요';
}
return null;
},
enabled: _hasEditPermission,
),
// 설명
const SizedBox(height: 16),
TextFormField(
controller: _descriptionController,
decoration: const InputDecoration(
labelText: '설명',
border: OutlineInputBorder(),
),
maxLines: 3,
enabled: _hasEditPermission,
),
// 위치
const SizedBox(height: 16),
TextFormField(
controller: _locationController,
decoration: const InputDecoration(
labelText: '위치',
border: OutlineInputBorder(),
),
enabled: _hasEditPermission,
),
// 여성 기본 핸디캡
const SizedBox(height: 16),
TextFormField(
controller: _femaleHandicapController,
decoration: const InputDecoration(
labelText: '여성 기본 핸디캡',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
validator: (value) {
if (value != null && value.isNotEmpty) {
final handicap = int.tryParse(value);
if (handicap == null) {
return '숫자를 입력해주세요';
}
if (handicap < 0) {
return '0 이상의 값을 입력해주세요';
}
}
return null;
},
enabled: _hasEditPermission,
),
// 평균 산정 기준
const SizedBox(height: 16),
DropdownButtonFormField<String>(
decoration: const InputDecoration(
labelText: '평균 산정 기준',
border: OutlineInputBorder(),
),
value: _selectedAverageCalculationPeriod,
items: averageCalculationPeriodOptions.entries
.map(
(entry) => DropdownMenuItem<String>(
value: entry.key,
child: Text(entry.value),
),
)
.toList(),
onChanged: _hasEditPermission
? (value) {
setState(() {
_selectedAverageCalculationPeriod = value;
});
}
: null,
),
// 모임장 설정
const SizedBox(height: 16),
DropdownButtonFormField<String>(
decoration: const InputDecoration(
labelText: '모임장 설정',
border: OutlineInputBorder(),
),
value: _selectedOwnerId,
items: _members.map((member) {
return DropdownMenuItem<String>(
value: member.userId,
child: Text(
'${member.name} (${getMemberTypeLabel(member.memberType)})',
),
);
}).toList(),
onChanged: _hasEditPermission
? (value) {
setState(() {
_selectedOwnerId = value;
});
}
: null,
),
// 저장 버튼
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _hasEditPermission
? _saveClubInfo
: null,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(
vertical: 16.0,
),
),
child: const Text('저장'),
),
),
],
),
),
),
),
],
),
);
}
}
@@ -0,0 +1,394 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:intl/intl.dart';
import '../../services/auth_service.dart';
import '../../services/club_service.dart';
import '../../services/member_service.dart';
import '../../services/event_service.dart';
import '../../models/club_model.dart';
import 'club_settings_screen.dart';
class DashboardScreen extends StatefulWidget {
const DashboardScreen({super.key});
@override
State<DashboardScreen> createState() => _DashboardScreenState();
}
class _DashboardScreenState extends State<DashboardScreen> {
bool _isLoading = false;
bool _isInit = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (!_isInit) {
_loadDashboardData();
_isInit = true;
}
}
Future<void> _loadDashboardData() async {
setState(() {
_isLoading = true;
});
try {
final authService = Provider.of<AuthService>(context, listen: false);
final clubService = Provider.of<ClubService>(context, listen: false);
// 클럽 정보 로드
if (clubService.currentClub == null) {
await clubService.initialize(authService.token!);
}
// 회원 및 이벤트 서비스 초기화
if (clubService.currentClub != null) {
final memberService = Provider.of<MemberService>(context, listen: false);
final eventService = Provider.of<EventService>(context, listen: false);
memberService.initialize(authService.token!);
eventService.initialize(authService.token!);
// 회원 및 이벤트 데이터 로드
await Future.wait([
memberService.fetchClubMembers(),
eventService.fetchClubEvents(),
]);
}
} catch (e) {
// 오류 처리
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('데이터 로드 중 오류가 발생했습니다: $e')),
);
} finally {
setState(() {
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: _isLoading
? const Center(child: CircularProgressIndicator())
: RefreshIndicator(
onRefresh: _loadDashboardData,
child: Consumer3<ClubService, MemberService, EventService>(
builder: (context, clubService, memberService, eventService, _) {
final club = clubService.currentClub;
if (club == null) {
return const Center(
child: Text('클럽 정보를 불러올 수 없습니다'),
);
}
return SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 클럽 정보 카드
_buildClubInfoCard(club),
const SizedBox(height: 16),
// 통계 카드들
_buildStatisticsCards(
memberCount: memberService.members.length,
eventCount: eventService.events.length,
),
const SizedBox(height: 16),
// 최근 이벤트 목록
_buildRecentEventsList(eventService),
],
),
);
},
),
),
);
}
Widget _buildClubInfoCard(Club club) {
return Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Row(
children: [
if (club.logo != null)
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
club.logo!,
width: 60,
height: 60,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Container(
width: 60,
height: 60,
color: Colors.grey[300],
child: const Icon(Icons.sports_golf),
);
},
),
)
else
Container(
width: 60,
height: 60,
decoration: BoxDecoration(
color: Colors.blue[100],
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.sports_golf,
size: 30,
color: Colors.blue,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
club.name,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
if (club.description != null)
Text(
club.description!,
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
),
IconButton(
icon: const Icon(Icons.settings),
tooltip: '클럽 설정',
onPressed: () {
Navigator.of(context).pushNamed(ClubSettingsScreen.routeName);
},
),
],
),
const SizedBox(height: 16),
if (club.address != null)
_buildInfoRow(Icons.location_on, club.address!),
if (club.phone != null)
_buildInfoRow(Icons.phone, club.phone!),
if (club.email != null)
_buildInfoRow(Icons.email, club.email!),
if (club.website != null)
_buildInfoRow(Icons.language, club.website!),
],
),
),
);
}
Widget _buildInfoRow(IconData icon, String text) {
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Row(
children: [
Icon(icon, size: 16, color: Colors.grey[600]),
const SizedBox(width: 8),
Expanded(
child: Text(
text,
style: TextStyle(fontSize: 14, color: Colors.grey[800]),
),
),
],
),
);
}
Widget _buildStatisticsCards({required int memberCount, required int eventCount}) {
return Row(
children: [
Expanded(
child: _buildStatCard(
title: '회원',
value: memberCount.toString(),
icon: Icons.people,
color: Colors.blue,
),
),
const SizedBox(width: 16),
Expanded(
child: _buildStatCard(
title: '이벤트',
value: eventCount.toString(),
icon: Icons.event,
color: Colors.orange,
),
),
],
);
}
Widget _buildStatCard({
required String title,
required String value,
required IconData icon,
required Color color,
}) {
return Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
Icon(icon, size: 32, color: color),
const SizedBox(height: 8),
Text(
value,
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: color,
),
),
Text(
title,
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
),
),
],
),
),
);
}
Widget _buildRecentEventsList(EventService eventService) {
final events = eventService.events;
if (events.isEmpty) {
return const Card(
child: Padding(
padding: EdgeInsets.all(16.0),
child: Center(
child: Text('예정된 이벤트가 없습니다'),
),
),
);
}
// 날짜 기준으로 정렬 (가까운 날짜순)
final sortedEvents = [...events];
sortedEvents.sort((a, b) => a.startDate.compareTo(b.startDate));
// 앞으로 진행될 이벤트만 필터링 (최대 3개)
final upcomingEvents = sortedEvents
.where((event) => event.startDate.isAfter(DateTime.now()))
.take(3)
.toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'다가오는 이벤트',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
TextButton(
onPressed: () {
// 이벤트 목록 화면으로 이동 (추후 구현)
},
child: const Text('모두 보기'),
),
],
),
const SizedBox(height: 8),
...upcomingEvents.map((event) => _buildEventCard(event)),
],
);
}
Widget _buildEventCard(event) {
final dateFormat = DateFormat('yyyy년 MM월 dd일 HH:mm');
return Card(
margin: const EdgeInsets.only(bottom: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
title: Text(
event.title,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
Text(
dateFormat.format(event.startDate),
style: TextStyle(
color: Colors.grey[700],
),
),
if (event.location != null)
Text(
event.location!,
style: TextStyle(
color: Colors.grey[600],
),
),
],
),
trailing: const Icon(Icons.arrow_forward_ios, size: 16),
onTap: () {
// 이벤트 상세 화면으로 이동 (추후 구현)
},
),
);
}
}
+671
View File
@@ -0,0 +1,671 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:intl/intl.dart';
import '../../services/auth_service.dart';
import '../../services/club_service.dart';
import '../../services/event_service.dart';
import '../../models/event_model.dart';
class EventsScreen extends StatefulWidget {
const EventsScreen({super.key});
@override
State<EventsScreen> createState() => _EventsScreenState();
}
class _EventsScreenState extends State<EventsScreen> {
bool _isInit = false;
String _searchQuery = '';
final TextEditingController _searchController = TextEditingController();
String _filterType = '전체'; // '전체', '예정', '지난'
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (!_isInit) {
_loadEvents();
_isInit = true;
}
}
Future<void> _loadEvents() async {
try {
final authService = Provider.of<AuthService>(context, listen: false);
final clubService = Provider.of<ClubService>(context, listen: false);
final eventService = Provider.of<EventService>(context, listen: false);
if (clubService.currentClub != null) {
eventService.initialize(authService.token!);
await eventService.fetchClubEvents();
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('이벤트 목록을 불러오는데 실패했습니다: $e')),
);
}
}
}
List<Event> _getFilteredEvents(List<Event> events) {
// 검색어 필터링
List<Event> filteredEvents = events;
if (_searchQuery.isNotEmpty) {
final query = _searchQuery.toLowerCase();
filteredEvents = filteredEvents.where((event) {
return event.title.toLowerCase().contains(query) ||
(event.description?.toLowerCase().contains(query) ?? false) ||
(event.location?.toLowerCase().contains(query) ?? false);
}).toList();
}
// 날짜 필터링
final now = DateTime.now();
if (_filterType == '예정') {
filteredEvents = filteredEvents.where((event) => event.startDate.isAfter(now)).toList();
} else if (_filterType == '지난') {
filteredEvents = filteredEvents.where((event) => event.startDate.isBefore(now)).toList();
}
// 날짜순 정렬
filteredEvents.sort((a, b) => a.startDate.compareTo(b.startDate));
return filteredEvents;
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: [
// 검색 바
Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: '이벤트 검색',
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
contentPadding: const EdgeInsets.symmetric(vertical: 0),
suffixIcon: _searchQuery.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
setState(() {
_searchQuery = '';
});
},
)
: null,
),
onChanged: (value) {
setState(() {
_searchQuery = value;
});
},
),
const SizedBox(height: 8),
// 필터 버튼들
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
_buildFilterChip('전체'),
const SizedBox(width: 8),
_buildFilterChip('예정'),
const SizedBox(width: 8),
_buildFilterChip('지난'),
],
),
),
],
),
),
// 이벤트 목록
Expanded(
child: Consumer<EventService>(
builder: (context, eventService, _) {
if (eventService.isLoading) {
return const Center(child: CircularProgressIndicator());
}
final filteredEvents = _getFilteredEvents(eventService.events);
if (filteredEvents.isEmpty) {
return Center(
child: Text(
_searchQuery.isEmpty
? '등록된 이벤트가 없습니다'
: '검색 결과가 없습니다',
),
);
}
return RefreshIndicator(
onRefresh: _loadEvents,
child: ListView.builder(
padding: const EdgeInsets.only(bottom: 16),
itemCount: filteredEvents.length,
itemBuilder: (context, index) {
final event = filteredEvents[index];
return _buildEventCard(event);
},
),
);
},
),
),
],
),
floatingActionButton: FloatingActionButton(
onPressed: () {
// 이벤트 추가 화면으로 이동 (추후 구현)
_showAddEventDialog();
},
backgroundColor: Colors.blue,
child: const Icon(Icons.add, color: Colors.white),
),
);
}
Widget _buildFilterChip(String label) {
final isSelected = _filterType == label;
return FilterChip(
label: Text(label),
selected: isSelected,
onSelected: (selected) {
setState(() {
_filterType = selected ? label : '전체';
});
},
backgroundColor: Colors.grey[200],
selectedColor: Colors.blue[100],
checkmarkColor: Colors.blue,
labelStyle: TextStyle(
color: isSelected ? Colors.blue[800] : Colors.black87,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
),
);
}
Widget _buildEventCard(Event event) {
final dateFormat = DateFormat('yyyy년 MM월 dd일 HH:mm');
final now = DateTime.now();
final isPast = event.startDate.isBefore(now);
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
leading: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: isPast ? Colors.grey[200] : Colors.blue[100],
borderRadius: BorderRadius.circular(8),
),
child: Icon(
Icons.event,
color: isPast ? Colors.grey[600] : Colors.blue,
),
),
title: Text(
event.title,
style: TextStyle(
fontWeight: FontWeight.bold,
color: isPast ? Colors.grey[600] : Colors.black,
),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
Text(
dateFormat.format(event.startDate),
style: TextStyle(
color: isPast ? Colors.grey[500] : Colors.grey[700],
),
),
if (event.location != null)
Text(
event.location!,
style: TextStyle(
color: isPast ? Colors.grey[500] : Colors.grey[600],
),
),
],
),
trailing: PopupMenuButton<String>(
icon: const Icon(Icons.more_vert),
onSelected: (value) {
if (value == 'edit') {
_showEditEventDialog(event);
} else if (value == 'delete') {
_showDeleteConfirmationDialog(event);
}
},
itemBuilder: (context) => [
const PopupMenuItem<String>(
value: 'edit',
child: Row(
children: [
Icon(Icons.edit, size: 18),
SizedBox(width: 8),
Text('수정'),
],
),
),
const PopupMenuItem<String>(
value: 'delete',
child: Row(
children: [
Icon(Icons.delete, size: 18, color: Colors.red),
SizedBox(width: 8),
Text('삭제', style: TextStyle(color: Colors.red)),
],
),
),
],
),
onTap: () {
// 이벤트 상세 화면으로 이동 (추후 구현)
_showEventDetailsDialog(event);
},
),
);
}
// 이벤트 추가 다이얼로그
void _showAddEventDialog() {
final titleController = TextEditingController();
final descriptionController = TextEditingController();
final locationController = TextEditingController();
DateTime selectedDate = DateTime.now().add(const Duration(days: 1));
TimeOfDay selectedTime = TimeOfDay.now();
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('이벤트 추가'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: titleController,
decoration: const InputDecoration(
labelText: '제목',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
TextField(
controller: descriptionController,
maxLines: 3,
decoration: const InputDecoration(
labelText: '설명',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
TextField(
controller: locationController,
decoration: const InputDecoration(
labelText: '장소',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
ListTile(
title: const Text('날짜'),
subtitle: Text(
DateFormat('yyyy년 MM월 dd일').format(selectedDate),
),
trailing: const Icon(Icons.calendar_today),
onTap: () async {
final pickedDate = await showDatePicker(
context: context,
initialDate: selectedDate,
firstDate: DateTime.now(),
lastDate: DateTime.now().add(const Duration(days: 365)),
);
if (pickedDate != null && context.mounted) {
setState(() {
selectedDate = pickedDate;
});
}
},
),
ListTile(
title: const Text('시간'),
subtitle: Text(selectedTime.format(context)),
trailing: const Icon(Icons.access_time),
onTap: () async {
final pickedTime = await showTimePicker(
context: context,
initialTime: selectedTime,
);
if (pickedTime != null && context.mounted) {
setState(() {
selectedTime = pickedTime;
});
}
},
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('취소'),
),
ElevatedButton(
onPressed: () async {
if (titleController.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('제목은 필수 입력 항목입니다')),
);
return;
}
try {
final eventService = Provider.of<EventService>(context, listen: false);
final clubService = Provider.of<ClubService>(context, listen: false);
if (clubService.currentClub != null) {
// 날짜와 시간 결합
final eventDateTime = DateTime(
selectedDate.year,
selectedDate.month,
selectedDate.day,
selectedTime.hour,
selectedTime.minute,
);
await eventService.createEvent({
'title': titleController.text.trim(),
'description': descriptionController.text.trim(),
'location': locationController.text.trim(),
'startDate': eventDateTime.toIso8601String(),
'clubId': clubService.currentClub!.id,
'isActive': true,
});
if (mounted) {
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('이벤트가 추가되었습니다')),
);
}
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('이벤트 추가에 실패했습니다: $e')),
);
}
},
child: const Text('추가'),
),
],
),
);
}
// 이벤트 수정 다이얼로그
void _showEditEventDialog(Event event) {
final titleController = TextEditingController(text: event.title);
final descriptionController = TextEditingController(text: event.description ?? '');
final locationController = TextEditingController(text: event.location ?? '');
DateTime selectedDate = event.startDate;
TimeOfDay selectedTime = TimeOfDay(
hour: event.startDate.hour,
minute: event.startDate.minute,
);
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('이벤트 수정'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: titleController,
decoration: const InputDecoration(
labelText: '제목',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
TextField(
controller: descriptionController,
maxLines: 3,
decoration: const InputDecoration(
labelText: '설명',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
TextField(
controller: locationController,
decoration: const InputDecoration(
labelText: '장소',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
ListTile(
title: const Text('날짜'),
subtitle: Text(
DateFormat('yyyy년 MM월 dd일').format(selectedDate),
),
trailing: const Icon(Icons.calendar_today),
onTap: () async {
final pickedDate = await showDatePicker(
context: context,
initialDate: selectedDate,
firstDate: DateTime.now().subtract(const Duration(days: 365)),
lastDate: DateTime.now().add(const Duration(days: 365)),
);
if (pickedDate != null && context.mounted) {
setState(() {
selectedDate = pickedDate;
});
}
},
),
ListTile(
title: const Text('시간'),
subtitle: Text(selectedTime.format(context)),
trailing: const Icon(Icons.access_time),
onTap: () async {
final pickedTime = await showTimePicker(
context: context,
initialTime: selectedTime,
);
if (pickedTime != null && context.mounted) {
setState(() {
selectedTime = pickedTime;
});
}
},
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('취소'),
),
ElevatedButton(
onPressed: () async {
if (titleController.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('제목은 필수 입력 항목입니다')),
);
return;
}
try {
final eventService = Provider.of<EventService>(context, listen: false);
// 날짜와 시간 결합
final eventDateTime = DateTime(
selectedDate.year,
selectedDate.month,
selectedDate.day,
selectedTime.hour,
selectedTime.minute,
);
await eventService.updateEvent(event.id, {
'title': titleController.text.trim(),
'description': descriptionController.text.trim(),
'location': locationController.text.trim(),
'startDate': eventDateTime.toIso8601String(),
});
if (mounted) {
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('이벤트가 업데이트되었습니다')),
);
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('이벤트 업데이트에 실패했습니다: $e')),
);
}
},
child: const Text('저장'),
),
],
),
);
}
// 이벤트 상세 정보 다이얼로그
void _showEventDetailsDialog(Event event) {
final dateFormat = DateFormat('yyyy년 MM월 dd일 HH:mm');
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(event.title),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.calendar_today, size: 16, color: Colors.blue),
const SizedBox(width: 8),
Text(dateFormat.format(event.startDate)),
],
),
const SizedBox(height: 8),
if (event.location != null) ...[
Row(
children: [
const Icon(Icons.location_on, size: 16, color: Colors.blue),
const SizedBox(width: 8),
Text(event.location!),
],
),
const SizedBox(height: 8),
],
if (event.description != null) ...[
const Divider(),
const SizedBox(height: 8),
Text(
event.description!,
style: const TextStyle(fontSize: 16),
),
],
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('닫기'),
),
TextButton(
onPressed: () {
Navigator.of(context).pop();
_showEditEventDialog(event);
},
child: const Text('수정'),
),
],
),
);
}
// 이벤트 삭제 확인 다이얼로그
void _showDeleteConfirmationDialog(Event event) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('이벤트 삭제'),
content: Text('${event.title} 이벤트를 정말 삭제하시겠습니까?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('취소'),
),
TextButton(
onPressed: () async {
try {
final eventService = Provider.of<EventService>(context, listen: false);
await eventService.deleteEvent(event.id);
if (mounted) {
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('이벤트가 삭제되었습니다')),
);
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('이벤트 삭제에 실패했습니다: $e')),
);
}
},
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('삭제'),
),
],
),
);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,501 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:intl/intl.dart';
import 'package:fl_chart/fl_chart.dart';
import '../../services/auth_service.dart';
import '../../services/club_service.dart';
import '../../services/member_service.dart';
import '../../services/score_service.dart';
import '../../models/member_model.dart';
import '../../models/score_model.dart';
import 'member_statistics_screen.dart';
import 'score_entry_screen.dart';
class ClubStatisticsScreen extends StatefulWidget {
const ClubStatisticsScreen({super.key});
@override
State<ClubStatisticsScreen> createState() => _ClubStatisticsScreenState();
}
class _ClubStatisticsScreenState extends State<ClubStatisticsScreen> {
bool _isLoading = false;
bool _isInit = false;
Map<String, ScoreStatistics> _clubStatistics = {};
List<Member> _members = [];
Map<String, List<Score>> _recentScores = {};
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (!_isInit) {
_loadClubStatistics();
_isInit = true;
}
}
Future<void> _loadClubStatistics() async {
setState(() {
_isLoading = true;
});
try {
final authService = Provider.of<AuthService>(context, listen: false);
final clubService = Provider.of<ClubService>(context, listen: false);
final memberService = Provider.of<MemberService>(context, listen: false);
final scoreService = Provider.of<ScoreService>(context, listen: false);
if (clubService.currentClub != null) {
// 점수 서비스 초기화
scoreService.initialize(authService.token!);
// 회원 목록 로드
await memberService.fetchClubMembers();
_members = memberService.members;
// 클럽 통계 로드
_clubStatistics = await scoreService.fetchClubStatistics();
// 각 회원별 최근 점수 로드 (최대 3개)
_recentScores = {};
for (final member in _members) {
try {
final scores = await scoreService.fetchMemberScores(member.id);
// 날짜 기준 정렬 (최신순)
scores.sort((a, b) => b.date.compareTo(a.date));
_recentScores[member.id] = scores.take(3).toList();
} catch (e) {
// 개별 회원 점수 로드 실패 시 빈 배열로 처리
_recentScores[member.id] = [];
}
}
}
} catch (e) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('데이터 로드 중 오류가 발생했습니다: $e')));
} finally {
setState(() {
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('클럽 통계')),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: RefreshIndicator(
onRefresh: _loadClubStatistics,
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 클럽 전체 통계
_buildClubOverallStats(),
const SizedBox(height: 24),
// 회원별 통계
const Text(
'회원별 통계',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
// 회원 목록
..._members.map((member) => _buildMemberStatsCard(member)),
],
),
),
),
floatingActionButton: FloatingActionButton(
onPressed: () async {
await Navigator.of(context).push(
MaterialPageRoute(builder: (context) => const ScoreEntryScreen()),
);
// 화면으로 돌아왔을 때 데이터 새로고침
_loadClubStatistics();
},
backgroundColor: Colors.blue,
child: const Icon(Icons.add, color: Colors.white),
),
);
}
Widget _buildClubOverallStats() {
// 클럽 전체 통계가 없는 경우
if (!_clubStatistics.containsKey('overall')) {
return const Card(
child: Padding(
padding: EdgeInsets.all(16.0),
child: Center(child: Text('클럽 전체 통계 데이터가 없습니다')),
),
);
}
final overallStats = _clubStatistics['overall']!;
return Card(
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'클럽 전체 통계',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: _buildStatItem(
label: '클럽 평균',
value: overallStats.average.toStringAsFixed(1),
icon: Icons.score,
color: Colors.blue,
),
),
Expanded(
child: _buildStatItem(
label: '최고 점수',
value: overallStats.highScore.toString(),
icon: Icons.emoji_events,
color: Colors.amber,
),
),
],
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: _buildStatItem(
label: '최저 점수',
value: overallStats.lowScore.toString(),
icon: Icons.arrow_downward,
color: Colors.red,
),
),
Expanded(
child: _buildStatItem(
label: '총 게임 수',
value: overallStats.gamesPlayed.toString(),
icon: Icons.sports,
color: Colors.green,
),
),
],
),
// 클럽 평균 점수 추이 그래프
const SizedBox(height: 24),
const Text(
'클럽 평균 점수 추이',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
SizedBox(height: 180, child: _buildClubAverageChart()),
// 추가 통계 정보
if (overallStats.additionalStats != null) ...[
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 8),
...overallStats.additionalStats!.entries.map((entry) {
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(entry.key, style: const TextStyle(fontSize: 14)),
Text(
entry.value.toString(),
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
],
),
);
}),
],
],
),
),
);
}
Widget _buildStatItem({
required String label,
required String value,
required IconData icon,
required Color color,
}) {
return Column(
children: [
Icon(icon, size: 28, color: color),
const SizedBox(height: 8),
Text(
value,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: color,
),
),
Text(label, style: TextStyle(fontSize: 12, color: Colors.grey[600])),
],
);
}
Widget _buildMemberStatsCard(Member member) {
// 회원 통계 정보
final memberStats = _clubStatistics[member.id];
// 회원 최근 점수
final recentScores = _recentScores[member.id] ?? [];
return Card(
margin: const EdgeInsets.only(bottom: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: InkWell(
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => MemberStatisticsScreen(memberId: member.id),
),
);
},
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 회원 정보
Row(
children: [
CircleAvatar(
radius: 24,
backgroundColor: Colors.blue.shade100,
backgroundImage: member.profileImage != null
? NetworkImage(member.profileImage!)
: null,
child: member.profileImage == null
? Text(
member.name.isNotEmpty
? member.name[0].toUpperCase()
: '?',
style: const TextStyle(
fontSize: 20,
color: Colors.blue,
fontWeight: FontWeight.bold,
),
)
: null,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
member.name,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
if (memberStats != null)
Text(
'평균: ${memberStats.average.toStringAsFixed(1)} | 최고: ${memberStats.highScore} | 게임: ${memberStats.gamesPlayed}',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
],
),
),
const Icon(Icons.chevron_right),
],
),
// 최근 점수 표시
if (recentScores.isNotEmpty) ...[
const SizedBox(height: 12),
const Divider(),
const SizedBox(height: 8),
const Text(
'최근 점수',
style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: recentScores.map((score) {
return Column(
children: [
Text(
score.totalScore.toString(),
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.blue,
),
),
Text(
DateFormat('MM/dd').format(score.date),
style: TextStyle(
fontSize: 10,
color: Colors.grey[600],
),
),
],
);
}).toList(),
),
],
],
),
),
),
);
}
Widget _buildClubAverageChart() {
// 클럽 통계가 없는 경우
if (!_clubStatistics.containsKey('overall')) {
return const Center(child: Text('통계 데이터가 없습니다'));
}
// 회원별 평균 점수 데이터 추출
final memberAverages = <String, double>{};
// 회원별 통계 정보에서 평균 점수 추출
_clubStatistics.forEach((key, stats) {
if (key != 'overall') {
// 회원 ID에 해당하는 회원 찾기
final member = _members.firstWhere(
(m) => m.id == key,
orElse: () => Member(
id: key,
userId: key,
name: 'Unknown',
email: '',
clubId: '',
isActive: true,
),
);
memberAverages[member.name] = stats.average;
}
});
// 평균 점수 기준으로 정렬 (내림차순)
final sortedEntries = memberAverages.entries.toList()
..sort((a, b) => b.value.compareTo(a.value));
// 최대 8명만 표시
final displayEntries = sortedEntries.length > 8
? sortedEntries.sublist(0, 8)
: sortedEntries;
// 그래프 데이터 생성
final barGroups = <BarChartGroupData>[];
for (int i = 0; i < displayEntries.length; i++) {
barGroups.add(
BarChartGroupData(
x: i,
barRods: [
BarChartRodData(
toY: displayEntries[i].value,
color: Colors.blue,
width: 16,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(4),
topRight: Radius.circular(4),
),
),
],
),
);
}
// 최소/최대 점수 계산 (Y축 범위 설정용)
final minScore = displayEntries.isEmpty
? 0.0
: displayEntries.map((e) => e.value).reduce((a, b) => a < b ? a : b) -
10;
final maxScore = displayEntries.isEmpty
? 300.0
: displayEntries.map((e) => e.value).reduce((a, b) => a > b ? a : b) +
10;
return BarChart(
BarChartData(
alignment: BarChartAlignment.spaceAround,
maxY: maxScore,
minY: minScore < 0 ? 0 : minScore,
gridData: const FlGridData(show: true, horizontalInterval: 50),
borderData: FlBorderData(
show: true,
border: Border.all(color: Colors.grey.shade300),
),
titlesData: FlTitlesData(
show: true,
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (value, meta) {
final index = value.toInt();
if (index >= 0 && index < displayEntries.length) {
// 회원 이름 표시 (최대 5글자)
final name = displayEntries[index].key;
final shortName = name.length > 5
? '${name.substring(0, 4)}...'
: name;
return Text(shortName, style: const TextStyle(fontSize: 10));
}
return const SizedBox();
},
reservedSize: 30,
),
),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
interval: 50,
getTitlesWidget: (value, meta) {
return Text(
value.toInt().toString(),
style: const TextStyle(fontSize: 10),
);
},
reservedSize: 40,
),
),
),
barGroups: barGroups,
),
);
}
}
@@ -0,0 +1,662 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:intl/intl.dart';
import 'package:fl_chart/fl_chart.dart';
import '../../services/auth_service.dart';
import '../../services/club_service.dart';
import '../../services/member_service.dart';
import '../../services/score_service.dart';
import '../../models/member_model.dart';
import '../../models/score_model.dart';
import 'score_entry_screen.dart';
class MemberStatisticsScreen extends StatefulWidget {
final String memberId;
const MemberStatisticsScreen({super.key, required this.memberId});
@override
State<MemberStatisticsScreen> createState() => _MemberStatisticsScreenState();
}
class _MemberStatisticsScreenState extends State<MemberStatisticsScreen>
with SingleTickerProviderStateMixin {
bool _isLoading = false;
bool _isInit = false;
late TabController _tabController;
Member? _member;
List<Score> _scores = [];
ScoreStatistics? _statistics;
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (!_isInit) {
_loadMemberData();
_isInit = true;
}
}
Future<void> _loadMemberData() async {
setState(() {
_isLoading = true;
});
try {
final authService = Provider.of<AuthService>(context, listen: false);
final clubService = Provider.of<ClubService>(context, listen: false);
final memberService = Provider.of<MemberService>(context, listen: false);
final scoreService = Provider.of<ScoreService>(context, listen: false);
if (clubService.currentClub != null) {
// 회원 정보 로드
_member = await memberService.fetchMemberById(widget.memberId);
// 점수 서비스 초기화
scoreService.initialize(authService.token!);
// 회원 점수 및 통계 로드
await Future.wait([
scoreService.fetchMemberScores(widget.memberId).then((scores) {
_scores = scores;
}),
scoreService.fetchMemberStatistics(widget.memberId).then((stats) {
_statistics = stats;
}),
]);
}
} catch (e) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('데이터 로드 중 오류가 발생했습니다: $e')));
} finally {
setState(() {
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(_member?.name ?? '회원 통계'),
bottom: TabBar(
controller: _tabController,
tabs: const [
Tab(text: '통계'),
Tab(text: '점수 기록'),
],
),
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: TabBarView(
controller: _tabController,
children: [_buildStatisticsTab(), _buildScoresTab()],
),
floatingActionButton: FloatingActionButton(
onPressed: () async {
await Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => ScoreEntryScreen(memberId: widget.memberId),
),
);
// 화면으로 돌아왔을 때 데이터 새로고침
_loadMemberData();
},
backgroundColor: Colors.blue,
child: const Icon(Icons.add, color: Colors.white),
),
);
}
Widget _buildStatisticsTab() {
if (_statistics == null) {
return const Center(child: Text('통계 데이터가 없습니다'));
}
return RefreshIndicator(
onRefresh: _loadMemberData,
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 회원 정보 카드
if (_member != null) _buildMemberInfoCard(),
const SizedBox(height: 24),
// 주요 통계 카드
_buildMainStatsCard(),
const SizedBox(height: 24),
// 추가 통계 정보
if (_statistics!.additionalStats != null)
_buildAdditionalStatsCard(),
// 통계 그래프
const SizedBox(height: 24),
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'점수 추이',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
SizedBox(height: 200, child: _buildScoreChart()),
],
),
),
),
],
),
),
);
}
Widget _buildMemberInfoCard() {
return Card(
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
CircleAvatar(
radius: 30,
backgroundColor: Colors.blue.shade100,
backgroundImage: _member!.profileImage != null
? NetworkImage(_member!.profileImage!)
: null,
child: _member!.profileImage == null
? Text(
_member!.name.isNotEmpty
? _member!.name[0].toUpperCase()
: '?',
style: const TextStyle(
fontSize: 24,
color: Colors.blue,
fontWeight: FontWeight.bold,
),
)
: null,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_member!.name,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
_member!.email,
style: TextStyle(color: Colors.grey[600]),
),
if (_member!.phone != null)
Text(
_member!.phone!,
style: TextStyle(color: Colors.grey[600]),
),
],
),
),
],
),
),
);
}
Widget _buildMainStatsCard() {
return Card(
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'주요 통계',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: _buildStatItem(
label: '평균 점수',
value: _statistics!.average.toStringAsFixed(1),
icon: Icons.score,
color: Colors.blue,
),
),
Expanded(
child: _buildStatItem(
label: '최고 점수',
value: _statistics!.highScore.toString(),
icon: Icons.emoji_events,
color: Colors.amber,
),
),
],
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: _buildStatItem(
label: '최저 점수',
value: _statistics!.lowScore.toString(),
icon: Icons.arrow_downward,
color: Colors.red,
),
),
Expanded(
child: _buildStatItem(
label: '게임 수',
value: _statistics!.gamesPlayed.toString(),
icon: Icons.sports,
color: Colors.green,
),
),
],
),
],
),
),
);
}
Widget _buildStatItem({
required String label,
required String value,
required IconData icon,
required Color color,
}) {
return Column(
children: [
Icon(icon, size: 32, color: color),
const SizedBox(height: 8),
Text(
value,
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: color,
),
),
Text(label, style: TextStyle(fontSize: 14, color: Colors.grey[600])),
],
);
}
Widget _buildAdditionalStatsCard() {
final additionalStats = _statistics!.additionalStats!;
return Card(
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'추가 통계',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
...additionalStats.entries.map((entry) {
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(entry.key, style: const TextStyle(fontSize: 16)),
Text(
entry.value.toString(),
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
],
),
);
}),
],
),
),
);
}
Widget _buildScoresTab() {
if (_scores.isEmpty) {
return const Center(child: Text('기록된 점수가 없습니다'));
}
// 날짜 기준으로 정렬 (최신순)
final sortedScores = [..._scores];
sortedScores.sort((a, b) => b.date.compareTo(a.date));
return RefreshIndicator(
onRefresh: _loadMemberData,
child: ListView.builder(
padding: const EdgeInsets.all(8.0),
itemCount: sortedScores.length,
itemBuilder: (context, index) {
final score = sortedScores[index];
return _buildScoreCard(score);
},
),
);
}
Widget _buildScoreCard(Score score) {
final dateFormat = DateFormat('yyyy년 MM월 dd일');
return Card(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: ExpansionTile(
title: Row(
children: [
Text(
score.totalScore.toString(),
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.blue,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
dateFormat.format(score.date),
style: const TextStyle(fontSize: 14),
),
if (score.notes != null && score.notes!.isNotEmpty)
Text(
score.notes!,
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'프레임 점수',
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
_buildFrameScores(score.frames),
if (score.notes != null && score.notes!.isNotEmpty) ...[
const SizedBox(height: 16),
const Text(
'메모',
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
Text(score.notes!),
],
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton.icon(
icon: const Icon(Icons.edit, size: 16),
label: const Text('수정'),
onPressed: () {
// 점수 수정 기능 (추후 구현)
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('점수 수정 기능은 아직 개발 중입니다')),
);
},
),
const SizedBox(width: 8),
TextButton.icon(
icon: const Icon(
Icons.delete,
size: 16,
color: Colors.red,
),
label: const Text(
'삭제',
style: TextStyle(color: Colors.red),
),
onPressed: () {
_showDeleteConfirmationDialog(score);
},
),
],
),
],
),
),
],
),
);
}
Widget _buildFrameScores(List<int> frames) {
return Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: List.generate(10, (index) {
return Expanded(
child: Container(
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey[300]!),
),
child: Column(
children: [
Text(
'${index + 1}',
style: TextStyle(fontSize: 10, color: Colors.grey[600]),
),
const SizedBox(height: 4),
Text(
index < frames.length ? frames[index].toString() : '-',
style: const TextStyle(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
],
),
),
);
}),
),
);
}
Widget _buildScoreChart() {
if (_scores.isEmpty) {
return const Center(child: Text('점수 데이터가 없습니다'));
}
// 날짜 기준으로 정렬 (오래된 순)
final sortedScores = [..._scores];
sortedScores.sort((a, b) => a.date.compareTo(b.date));
// 최대 10개의 최근 점수만 표시
final displayScores = sortedScores.length > 10
? sortedScores.sublist(sortedScores.length - 10)
: sortedScores;
// 최소/최대 점수 계산 (Y축 범위 설정용)
final minScore =
displayScores.map((s) => s.totalScore).reduce((a, b) => a < b ? a : b) -
10;
final maxScore =
displayScores.map((s) => s.totalScore).reduce((a, b) => a > b ? a : b) +
10;
// 그래프 데이터 포인트 생성
final spots = <FlSpot>[];
for (int i = 0; i < displayScores.length; i++) {
spots.add(FlSpot(i.toDouble(), displayScores[i].totalScore.toDouble()));
}
return LineChart(
LineChartData(
gridData: FlGridData(
show: true,
drawVerticalLine: true,
horizontalInterval: 20,
verticalInterval: 1,
),
titlesData: FlTitlesData(
show: true,
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 30,
interval: 1,
getTitlesWidget: (value, meta) {
final index = value.toInt();
if (index >= 0 && index < displayScores.length) {
return Text(
DateFormat('MM/dd').format(displayScores[index].date),
style: const TextStyle(fontSize: 10),
);
}
return const SizedBox();
},
),
),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
interval: 50,
getTitlesWidget: (value, meta) {
return Text(
value.toInt().toString(),
style: const TextStyle(fontSize: 10),
);
},
reservedSize: 40,
),
),
),
borderData: FlBorderData(
show: true,
border: Border.all(color: Colors.grey.shade300),
),
minX: 0,
maxX: displayScores.length - 1.0,
minY: minScore.toDouble(),
maxY: maxScore.toDouble(),
lineBarsData: [
LineChartBarData(
spots: spots,
isCurved: false,
color: Colors.blue,
barWidth: 3,
isStrokeCapRound: true,
dotData: const FlDotData(show: true),
belowBarData: BarAreaData(
show: true,
color: Colors.blue.withOpacity(0.2),
),
),
],
),
);
}
void _showDeleteConfirmationDialog(Score score) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('점수 삭제'),
content: const Text('이 점수 기록을 정말 삭제하시겠습니까?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('취소'),
),
TextButton(
onPressed: () async {
Navigator.of(context).pop();
try {
final scoreService = Provider.of<ScoreService>(
context,
listen: false,
);
await scoreService.deleteScore(score.id);
// 데이터 새로고침
_loadMemberData();
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('점수가 삭제되었습니다')));
}
} catch (e) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('점수 삭제에 실패했습니다: $e')));
}
},
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('삭제'),
),
],
),
);
}
}
@@ -0,0 +1,378 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:intl/intl.dart';
import '../../services/club_service.dart';
import '../../services/member_service.dart';
import '../../services/event_service.dart';
import '../../services/score_service.dart';
class ScoreEntryScreen extends StatefulWidget {
final String? memberId;
final String? eventId;
const ScoreEntryScreen({
super.key,
this.memberId,
this.eventId,
});
@override
State<ScoreEntryScreen> createState() => _ScoreEntryScreenState();
}
class _ScoreEntryScreenState extends State<ScoreEntryScreen> {
final _formKey = GlobalKey<FormState>();
bool _isLoading = false;
// 점수 입력 관련 상태
final List<TextEditingController> _frameControllers = List.generate(
10,
(_) => TextEditingController(),
);
final TextEditingController _notesController = TextEditingController();
// 선택된 회원 및 이벤트
String? _selectedMemberId;
String? _selectedEventId;
DateTime _selectedDate = DateTime.now();
@override
void initState() {
super.initState();
_selectedMemberId = widget.memberId;
_selectedEventId = widget.eventId;
}
@override
void dispose() {
for (var controller in _frameControllers) {
controller.dispose();
}
_notesController.dispose();
super.dispose();
}
// 총점 계산
int _calculateTotalScore() {
int total = 0;
for (var controller in _frameControllers) {
final value = int.tryParse(controller.text) ?? 0;
total += value;
}
return total;
}
// 점수 저장
Future<void> _saveScore() async {
if (!_formKey.currentState!.validate()) {
return;
}
if (_selectedMemberId == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('회원을 선택해주세요')),
);
return;
}
setState(() {
_isLoading = true;
});
try {
final scoreService = Provider.of<ScoreService>(context, listen: false);
final clubService = Provider.of<ClubService>(context, listen: false);
if (clubService.currentClub != null) {
// 프레임 점수 배열 생성
final frames = _frameControllers
.map((controller) => int.tryParse(controller.text) ?? 0)
.toList();
// 총점 계산
final totalScore = _calculateTotalScore();
// 점수 데이터 생성
final scoreData = {
'memberId': _selectedMemberId,
'eventId': _selectedEventId,
'clubId': clubService.currentClub!.id,
'frames': frames,
'totalScore': totalScore,
'date': _selectedDate.toIso8601String(),
'notes': _notesController.text.isNotEmpty ? _notesController.text : null,
};
// 점수 저장
await scoreService.addScore(scoreData);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('점수가 저장되었습니다')),
);
Navigator.of(context).pop();
}
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('점수 저장에 실패했습니다: $e')),
);
} finally {
setState(() {
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('점수 입력'),
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: Form(
key: _formKey,
child: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 회원 선택
_buildMemberSelector(),
const SizedBox(height: 16),
// 이벤트 선택
_buildEventSelector(),
const SizedBox(height: 16),
// 날짜 선택
_buildDateSelector(),
const SizedBox(height: 24),
// 프레임 점수 입력
_buildFrameInputs(),
const SizedBox(height: 24),
// 총점 표시
_buildTotalScore(),
const SizedBox(height: 16),
// 메모 입력
TextFormField(
controller: _notesController,
decoration: const InputDecoration(
labelText: '메모',
border: OutlineInputBorder(),
),
maxLines: 3,
),
const SizedBox(height: 24),
// 저장 버튼
SizedBox(
width: double.infinity,
height: 50,
child: ElevatedButton(
onPressed: _saveScore,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
),
child: const Text('점수 저장'),
),
),
],
),
),
),
);
}
Widget _buildMemberSelector() {
return Consumer<MemberService>(
builder: (context, memberService, _) {
final members = memberService.members;
return DropdownButtonFormField<String>(
decoration: const InputDecoration(
labelText: '회원 선택',
border: OutlineInputBorder(),
),
value: _selectedMemberId,
items: members.map((member) {
return DropdownMenuItem<String>(
value: member.id,
child: Text(member.name),
);
}).toList(),
onChanged: (value) {
setState(() {
_selectedMemberId = value;
});
},
validator: (value) {
if (value == null || value.isEmpty) {
return '회원을 선택해주세요';
}
return null;
},
);
},
);
}
Widget _buildEventSelector() {
return Consumer<EventService>(
builder: (context, eventService, _) {
final events = eventService.events;
return DropdownButtonFormField<String>(
decoration: const InputDecoration(
labelText: '이벤트 선택 (선택사항)',
border: OutlineInputBorder(),
),
value: _selectedEventId,
items: [
const DropdownMenuItem<String>(
value: null,
child: Text('이벤트 없음'),
),
...events.map((event) {
return DropdownMenuItem<String>(
value: event.id,
child: Text(event.title),
);
}),
],
onChanged: (value) {
setState(() {
_selectedEventId = value;
});
},
);
},
);
}
Widget _buildDateSelector() {
return InkWell(
onTap: () async {
final pickedDate = await showDatePicker(
context: context,
initialDate: _selectedDate,
firstDate: DateTime.now().subtract(const Duration(days: 365)),
lastDate: DateTime.now(),
);
if (pickedDate != null) {
setState(() {
_selectedDate = pickedDate;
});
}
},
child: InputDecorator(
decoration: const InputDecoration(
labelText: '날짜',
border: OutlineInputBorder(),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(DateFormat('yyyy년 MM월 dd일').format(_selectedDate)),
const Icon(Icons.calendar_today),
],
),
),
);
}
Widget _buildFrameInputs() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'프레임 점수',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 5,
childAspectRatio: 1.5,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
),
itemCount: 10,
itemBuilder: (context, index) {
return TextFormField(
controller: _frameControllers[index],
keyboardType: TextInputType.number,
textAlign: TextAlign.center,
decoration: InputDecoration(
labelText: '${index + 1}프레임',
border: const OutlineInputBorder(),
),
onChanged: (_) {
setState(() {});
},
validator: (value) {
if (value == null || value.isEmpty) {
return '입력';
}
final score = int.tryParse(value);
if (score == null) {
return '숫자';
}
if (score < 0 || score > 30) {
return '0-30';
}
return null;
},
);
},
),
],
);
}
Widget _buildTotalScore() {
final totalScore = _calculateTotalScore();
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.blue.shade200),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'총점',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
Text(
'$totalScore',
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.blue,
),
),
],
),
);
}
}
@@ -0,0 +1,393 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../models/subscription_model.dart';
import '../../services/subscription_service.dart';
class SubscriptionDetailsScreen extends StatefulWidget {
final SubscriptionPlan plan;
final bool isYearly;
const SubscriptionDetailsScreen({
super.key,
required this.plan,
required this.isYearly,
});
@override
State<SubscriptionDetailsScreen> createState() => _SubscriptionDetailsScreenState();
}
class _SubscriptionDetailsScreenState extends State<SubscriptionDetailsScreen> {
bool _isLoading = false;
@override
Widget build(BuildContext context) {
final price = widget.isYearly ? widget.plan.yearlyPrice : widget.plan.monthlyPrice;
final period = widget.isYearly ? '' : '';
return Scaffold(
appBar: AppBar(
title: Text('${widget.plan.name} 플랜'),
),
body: Stack(
children: [
SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 플랜 헤더
_buildPlanHeader(price, period),
const SizedBox(height: 24),
// 플랜 설명
Text(
widget.plan.description,
style: const TextStyle(fontSize: 16),
),
const SizedBox(height: 24),
// 플랜 혜택
const Text(
'플랜 혜택',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
...widget.plan.features.map((feature) => _buildFeatureItem(feature)),
const SizedBox(height: 24),
// 플랜 세부 정보
const Text(
'세부 정보',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
_buildDetailItem('최대 회원 수', '${widget.plan.maxMembers}'),
_buildDetailItem('최대 이벤트 수', '${widget.plan.maxEvents}'),
_buildDetailItem(
'고급 통계 기능',
widget.plan.hasAdvancedStats ? '제공' : '미제공',
isAvailable: widget.plan.hasAdvancedStats,
),
_buildDetailItem(
'맞춤형 기능',
widget.plan.hasCustomization ? '제공' : '미제공',
isAvailable: widget.plan.hasCustomization,
),
_buildDetailItem(
'우선 지원',
widget.plan.hasPriority ? '제공' : '미제공',
isAvailable: widget.plan.hasPriority,
),
const SizedBox(height: 24),
// 결제 정보
const Text(
'결제 정보',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
_buildDetailItem(
'결제 주기',
widget.isYearly ? '연간' : '월간',
),
_buildDetailItem(
'결제 금액',
'${_formatPrice(price)}/${widget.isYearly ? '' : ''}',
),
if (widget.isYearly)
_buildDetailItem(
'월 환산 금액',
'${_formatPrice(widget.plan.yearlyPrice / 12)}/월',
),
_buildDetailItem(
'자동 갱신',
'활성화 (설정에서 변경 가능)',
),
// 하단 여백 (버튼 높이만큼)
const SizedBox(height: 80),
],
),
),
// 하단 구독 버튼
Positioned(
left: 0,
right: 0,
bottom: 0,
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 10,
offset: const Offset(0, -5),
),
],
),
child: ElevatedButton(
onPressed: _isLoading ? null : () => _subscribe(context),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
minimumSize: const Size(double.infinity, 50),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: _isLoading
? const CircularProgressIndicator(color: Colors.white)
: Text(
widget.plan.type == SubscriptionPlanType.free
? '무료 플랜 시작하기'
: '구독하기',
),
),
),
),
],
),
);
}
Widget _buildPlanHeader(double price, String period) {
return Card(
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: _getPlanColor(),
width: 2,
),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Row(
children: [
Icon(
_getPlanIcon(),
color: _getPlanColor(),
size: 32,
),
const SizedBox(width: 16),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.plan.name,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
if (widget.plan.type == SubscriptionPlanType.premium)
Container(
margin: const EdgeInsets.only(top: 4),
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: Colors.amber.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.amber),
),
child: const Text(
'인기 플랜',
style: TextStyle(
color: Colors.amber,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
),
],
),
],
),
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${_formatPrice(price)}',
style: const TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 4),
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
'/$period',
style: const TextStyle(fontSize: 16),
),
),
],
),
if (widget.isYearly)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
'월 ₩${_formatPrice(widget.plan.yearlyPrice / 12)} (20% 할인)',
style: TextStyle(
color: Colors.green[700],
fontWeight: FontWeight.bold,
),
),
),
],
),
),
);
}
Widget _buildFeatureItem(String feature) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.check_circle, color: Colors.green, size: 20),
const SizedBox(width: 12),
Expanded(child: Text(feature, style: const TextStyle(fontSize: 16))),
],
),
);
}
Widget _buildDetailItem(String label, String value, {bool isAvailable = true}) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
children: [
Expanded(
flex: 2,
child: Text(
label,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
),
Expanded(
flex: 3,
child: Text(
value,
style: TextStyle(
fontSize: 16,
color: isAvailable ? null : Colors.grey,
),
),
),
],
),
);
}
Color _getPlanColor() {
switch (widget.plan.type) {
case SubscriptionPlanType.free:
return Colors.grey;
case SubscriptionPlanType.basic:
return Colors.blue;
case SubscriptionPlanType.premium:
return Colors.amber;
case SubscriptionPlanType.enterprise:
return Colors.purple;
}
}
IconData _getPlanIcon() {
switch (widget.plan.type) {
case SubscriptionPlanType.free:
return Icons.star_border;
case SubscriptionPlanType.basic:
return Icons.star_half;
case SubscriptionPlanType.premium:
return Icons.star;
case SubscriptionPlanType.enterprise:
return Icons.workspace_premium;
}
}
String _formatPrice(double price) {
if (price == 0) {
return '0';
}
// 천 단위 콤마 추가
final priceInt = price.toInt();
return priceInt.toString().replaceAllMapped(
RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'),
(Match m) => '${m[1]},',
);
}
Future<void> _subscribe(BuildContext context) async {
setState(() {
_isLoading = true;
});
try {
final subscriptionService = Provider.of<SubscriptionService>(context, listen: false);
final success = await subscriptionService.createSubscription(
widget.plan.type,
widget.isYearly,
);
if (success && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('구독이 성공적으로 완료되었습니다')),
);
Navigator.of(context).pop(); // 상세 화면 닫기
} else if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(subscriptionService.error ?? '구독 처리 중 오류가 발생했습니다'),
backgroundColor: Colors.red,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('오류: $e'),
backgroundColor: Colors.red,
),
);
}
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
}
@@ -0,0 +1,486 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../models/subscription_model.dart';
import '../../services/subscription_service.dart';
import 'subscription_details_screen.dart';
class SubscriptionScreen extends StatefulWidget {
const SubscriptionScreen({super.key});
@override
State<SubscriptionScreen> createState() => _SubscriptionScreenState();
}
class _SubscriptionScreenState extends State<SubscriptionScreen> {
bool _isYearly = true; // 기본값은 연간 구독
@override
void initState() {
super.initState();
// 화면 로드 시 구독 정보 조회
WidgetsBinding.instance.addPostFrameCallback((_) {
final subscriptionService = Provider.of<SubscriptionService>(
context,
listen: false,
);
subscriptionService.fetchCurrentSubscription();
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('구독 관리')),
body: Consumer<SubscriptionService>(
builder: (context, subscriptionService, child) {
if (subscriptionService.isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (subscriptionService.error != null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('오류가 발생했습니다', style: TextStyle(color: Colors.red[700])),
const SizedBox(height: 8),
Text(subscriptionService.error!),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () =>
subscriptionService.fetchCurrentSubscription(),
child: const Text('다시 시도'),
),
],
),
);
}
// 현재 구독 정보가 있는 경우
if (subscriptionService.hasSubscription) {
return _buildCurrentSubscription(subscriptionService);
}
// 구독 정보가 없는 경우 플랜 선택 화면
return _buildSubscriptionPlans(subscriptionService);
},
),
);
}
Widget _buildCurrentSubscription(SubscriptionService subscriptionService) {
final subscription = subscriptionService.currentSubscription!;
final plan = subscriptionService.availablePlans.firstWhere(
(plan) => plan.type == subscription.planType,
orElse: () => SubscriptionPlan.getDefaultPlans().first,
);
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 현재 구독 정보 카드
Card(
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: subscription.isActive ? Colors.green : Colors.grey,
width: 2,
),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.verified,
color: subscription.isActive
? Colors.green
: Colors.grey,
size: 28,
),
const SizedBox(width: 8),
Text(
'${plan.name} 플랜',
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: subscription.isActive
? Colors.green.withOpacity(0.1)
: Colors.grey.withOpacity(0.1),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: subscription.isActive
? Colors.green
: Colors.grey,
),
),
child: Text(
subscription.isActive ? '활성' : '만료됨',
style: TextStyle(
color: subscription.isActive
? Colors.green
: Colors.grey,
fontWeight: FontWeight.bold,
),
),
),
],
),
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 16),
_buildInfoRow('시작일', _formatDate(subscription.startDate)),
const SizedBox(height: 8),
_buildInfoRow('만료일', _formatDate(subscription.endDate)),
const SizedBox(height: 8),
_buildInfoRow(
'자동 갱신',
subscription.autoRenew ? '활성화' : '비활성화',
),
const SizedBox(height: 8),
_buildInfoRow('남은 기간', '${subscription.daysLeft}'),
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 16),
const Text(
'플랜 혜택',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
...plan.features.map(
(feature) => Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Row(
children: [
const Icon(
Icons.check_circle,
color: Colors.green,
size: 16,
),
const SizedBox(width: 8),
Text(feature),
],
),
),
),
],
),
),
),
const SizedBox(height: 24),
// 구독 관리 버튼
Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: subscription.isActive
? () => _showCancelDialog(subscriptionService)
: null,
icon: const Icon(Icons.cancel),
label: const Text('구독 취소'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red.shade100,
foregroundColor: Colors.red.shade700,
),
),
),
const SizedBox(width: 16),
Expanded(
child: ElevatedButton.icon(
onPressed: () => _toggleAutoRenew(subscriptionService),
icon: Icon(
subscription.autoRenew ? Icons.toggle_on : Icons.toggle_off,
),
label: Text(subscription.autoRenew ? '자동 갱신 끄기' : '자동 갱신 켜기'),
),
),
],
),
const SizedBox(height: 16),
// 플랜 변경 버튼
ElevatedButton.icon(
onPressed: () {
setState(() {
// 플랜 선택 화면으로 전환
subscriptionService.fetchCurrentSubscription();
});
},
icon: const Icon(Icons.upgrade),
label: const Text('플랜 변경하기'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
minimumSize: const Size(double.infinity, 50),
),
),
],
),
);
}
Widget _buildSubscriptionPlans(SubscriptionService subscriptionService) {
final plans = subscriptionService.availablePlans;
return Column(
children: [
// 연간/월간 선택 토글
Padding(
padding: const EdgeInsets.all(16),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('월간 구독'),
const SizedBox(width: 8),
Switch(
value: _isYearly,
onChanged: (value) {
setState(() {
_isYearly = value;
});
},
activeColor: Colors.blue,
),
const SizedBox(width: 8),
Row(
children: [
const Text('연간 구독'),
Container(
margin: const EdgeInsets.only(left: 8),
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: Colors.green.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.green),
),
child: const Text(
'20% 할인',
style: TextStyle(
color: Colors.green,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
),
],
),
],
),
),
// 플랜 목록
Expanded(
child: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: plans.length,
itemBuilder: (context, index) {
final plan = plans[index];
final price = _isYearly ? plan.yearlyPrice : plan.monthlyPrice;
final period = _isYearly ? '' : '';
return Card(
margin: const EdgeInsets.only(bottom: 16),
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: InkWell(
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => SubscriptionDetailsScreen(
plan: plan,
isYearly: _isYearly,
),
),
);
},
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
plan.name,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
if (plan.type == SubscriptionPlanType.premium)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.amber.withOpacity(0.1),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.amber),
),
child: const Text(
'인기',
style: TextStyle(
color: Colors.amber,
fontWeight: FontWeight.bold,
),
),
),
],
),
const SizedBox(height: 8),
Text(plan.description),
const SizedBox(height: 16),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${_formatPrice(price)}',
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 4),
Text('/$period'),
const Spacer(),
const Icon(Icons.arrow_forward),
],
),
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 8),
...plan.features
.take(3)
.map(
(feature) => Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Row(
children: [
const Icon(
Icons.check_circle,
color: Colors.green,
size: 16,
),
const SizedBox(width: 8),
Text(feature),
],
),
),
),
if (plan.features.length > 3)
Text(
'${plan.features.length - 3}개 혜택',
style: TextStyle(
color: Colors.grey[600],
fontStyle: FontStyle.italic,
),
),
],
),
),
),
);
},
),
),
],
);
}
Widget _buildInfoRow(String label, String value) {
return Row(
children: [
Text('$label: ', style: const TextStyle(fontWeight: FontWeight.bold)),
Text(value),
],
);
}
String _formatDate(DateTime date) {
return '${date.year}${date.month}${date.day}';
}
String _formatPrice(double price) {
if (price == 0) {
return '0';
}
// 천 단위 콤마 추가
final priceInt = price.toInt();
return priceInt.toString().replaceAllMapped(
RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'),
(Match m) => '${m[1]},',
);
}
void _showCancelDialog(SubscriptionService subscriptionService) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('구독 취소'),
content: const Text(
'정말로 구독을 취소하시겠습니까?\n'
'취소하면 현재 구독 기간이 끝날 때까지만 서비스를 이용할 수 있습니다.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('아니오'),
),
TextButton(
onPressed: () async {
Navigator.of(context).pop();
final success = await subscriptionService.cancelSubscription();
if (success && mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('구독이 취소되었습니다')));
}
},
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('예, 취소합니다'),
),
],
),
);
}
void _toggleAutoRenew(SubscriptionService subscriptionService) async {
final success = await subscriptionService.toggleAutoRenew();
if (success && mounted) {
final isAutoRenew = subscriptionService.currentSubscription!.autoRenew;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(isAutoRenew ? '자동 갱신이 활성화되었습니다' : '자동 갱신이 비활성화되었습니다'),
),
);
}
}
}
+358
View File
@@ -0,0 +1,358 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import '../config/api_config.dart';
import '../models/admin_model.dart';
/// 관리자 기능 관련 서비스
class AdminService extends ChangeNotifier {
final String _baseUrl = ApiConfig.baseUrl;
final String _token;
final String _clubId;
List<MemberRole> _memberRoles = [];
ClubSettings? _clubSettings;
UserRole _currentUserRole = UserRole.member;
AdminPermission _currentUserPermissions = AdminPermission();
bool _isLoading = false;
String? _error;
AdminService(this._token, this._clubId) {
_initialize();
}
// 게터
List<MemberRole> get memberRoles => _memberRoles;
ClubSettings? get clubSettings => _clubSettings;
UserRole get currentUserRole => _currentUserRole;
AdminPermission get currentUserPermissions => _currentUserPermissions;
bool get isLoading => _isLoading;
String? get error => _error;
// 권한 확인 게터
bool get canManageMembers => _currentUserPermissions.canManageMembers;
bool get canManageEvents => _currentUserPermissions.canManageEvents;
bool get canManageScores => _currentUserPermissions.canManageScores;
bool get canManageSettings => _currentUserPermissions.canManageSettings;
bool get canManageSubscription => _currentUserPermissions.canManageSubscription;
bool get canManageRoles => _currentUserPermissions.canManageRoles;
bool get canViewFinancials => _currentUserPermissions.canViewFinancials;
bool get canExportData => _currentUserPermissions.canExportData;
/// 초기화 (현재 사용자 권한 및 클럽 설정 로드)
Future<void> _initialize() async {
await Future.wait([
fetchCurrentUserRole(),
fetchClubSettings(),
]);
}
/// 현재 사용자의 역할 및 권한 조회
Future<void> fetchCurrentUserRole() async {
_isLoading = true;
_error = null;
notifyListeners();
try {
final response = await http.get(
Uri.parse('$_baseUrl/api/clubs/$_clubId/my-role'),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $_token',
},
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
final memberRole = MemberRole.fromJson(data);
_currentUserRole = memberRole.role;
_currentUserPermissions = memberRole.permissions;
} else {
// 기본값: 일반 회원
_currentUserRole = UserRole.member;
_currentUserPermissions = AdminPermission.fromRole(UserRole.member);
_error = '사용자 역할 정보를 불러오는데 실패했습니다. (${response.statusCode})';
}
} catch (e) {
_error = '사용자 역할 정보를 불러오는데 실패했습니다: $e';
// 기본값: 일반 회원
_currentUserRole = UserRole.member;
_currentUserPermissions = AdminPermission.fromRole(UserRole.member);
} finally {
_isLoading = false;
notifyListeners();
}
}
/// 클럽 내 모든 회원의 역할 정보 조회
Future<void> fetchMemberRoles() async {
if (!canManageRoles) {
_error = '역할 관리 권한이 없습니다.';
notifyListeners();
return;
}
_isLoading = true;
_error = null;
notifyListeners();
try {
final response = await http.get(
Uri.parse('$_baseUrl/api/clubs/$_clubId/roles'),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $_token',
},
);
if (response.statusCode == 200) {
final List<dynamic> data = jsonDecode(response.body);
_memberRoles = data.map((role) => MemberRole.fromJson(role)).toList();
} else {
_error = '회원 역할 정보를 불러오는데 실패했습니다. (${response.statusCode})';
}
} catch (e) {
_error = '회원 역할 정보를 불러오는데 실패했습니다: $e';
} finally {
_isLoading = false;
notifyListeners();
}
}
/// 회원 역할 변경
Future<bool> updateMemberRole(String memberId, UserRole newRole) async {
if (!canManageRoles) {
_error = '역할 관리 권한이 없습니다.';
notifyListeners();
return false;
}
_isLoading = true;
_error = null;
notifyListeners();
try {
final response = await http.put(
Uri.parse('$_baseUrl/api/clubs/$_clubId/members/$memberId/role'),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $_token',
},
body: jsonEncode({
'role': newRole.toString().split('.').last,
}),
);
if (response.statusCode == 200) {
// 역할 목록 갱신
await fetchMemberRoles();
return true;
} else {
_error = '회원 역할 변경에 실패했습니다. (${response.statusCode})';
return false;
}
} catch (e) {
_error = '회원 역할 변경에 실패했습니다: $e';
return false;
} finally {
_isLoading = false;
notifyListeners();
}
}
/// 클럽 설정 조회
Future<void> fetchClubSettings() async {
_isLoading = true;
_error = null;
notifyListeners();
try {
final response = await http.get(
Uri.parse('$_baseUrl/api/clubs/$_clubId/settings'),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $_token',
},
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
_clubSettings = ClubSettings.fromJson(data);
} else {
_error = '클럽 설정을 불러오는데 실패했습니다. (${response.statusCode})';
}
} catch (e) {
_error = '클럽 설정을 불러오는데 실패했습니다: $e';
} finally {
_isLoading = false;
notifyListeners();
}
}
/// 클럽 설정 업데이트
Future<bool> updateClubSettings(ClubSettings updatedSettings) async {
if (!canManageSettings) {
_error = '설정 관리 권한이 없습니다.';
notifyListeners();
return false;
}
_isLoading = true;
_error = null;
notifyListeners();
try {
final response = await http.put(
Uri.parse('$_baseUrl/api/clubs/$_clubId/settings'),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $_token',
},
body: jsonEncode(updatedSettings.toJson()),
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
_clubSettings = ClubSettings.fromJson(data);
notifyListeners();
return true;
} else {
_error = '클럽 설정 업데이트에 실패했습니다. (${response.statusCode})';
return false;
}
} catch (e) {
_error = '클럽 설정 업데이트에 실패했습니다: $e';
return false;
} finally {
_isLoading = false;
notifyListeners();
}
}
/// 클럽 로고 업로드
Future<bool> uploadClubLogo(String filePath) async {
if (!canManageSettings) {
_error = '설정 관리 권한이 없습니다.';
notifyListeners();
return false;
}
_isLoading = true;
_error = null;
notifyListeners();
try {
// 파일 업로드 로직 구현 (multipart/form-data)
// 실제 구현 시 http.MultipartRequest 사용
// 성공 시 설정 업데이트
await fetchClubSettings();
return true;
} catch (e) {
_error = '클럽 로고 업로드에 실패했습니다: $e';
return false;
} finally {
_isLoading = false;
notifyListeners();
}
}
/// 클럽 데이터 내보내기
Future<String?> exportClubData(String dataType) async {
if (!canExportData) {
_error = '데이터 내보내기 권한이 없습니다.';
notifyListeners();
return null;
}
_isLoading = true;
_error = null;
notifyListeners();
try {
final response = await http.get(
Uri.parse('$_baseUrl/api/clubs/$_clubId/export/$dataType'),
headers: {
'Authorization': 'Bearer $_token',
},
);
if (response.statusCode == 200) {
// 다운로드 URL 또는 데이터 반환
final data = jsonDecode(response.body);
return data['downloadUrl'] ?? data['data'];
} else {
_error = '데이터 내보내기에 실패했습니다. (${response.statusCode})';
return null;
}
} catch (e) {
_error = '데이터 내보내기에 실패했습니다: $e';
return null;
} finally {
_isLoading = false;
notifyListeners();
}
}
/// 클럽 통계 및 분석 데이터 조회
Future<Map<String, dynamic>?> fetchClubAnalytics() async {
if (!canViewFinancials) {
_error = '통계 조회 권한이 없습니다.';
notifyListeners();
return null;
}
_isLoading = true;
_error = null;
notifyListeners();
try {
final response = await http.get(
Uri.parse('$_baseUrl/api/clubs/$_clubId/analytics'),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $_token',
},
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
return data;
} else {
_error = '클럽 통계 데이터를 불러오는데 실패했습니다. (${response.statusCode})';
return null;
}
} catch (e) {
_error = '클럽 통계 데이터를 불러오는데 실패했습니다: $e';
return null;
} finally {
_isLoading = false;
notifyListeners();
}
}
/// 특정 권한이 있는지 확인
bool hasPermission(String permissionName) {
switch (permissionName) {
case 'manageMembers':
return _currentUserPermissions.canManageMembers;
case 'manageEvents':
return _currentUserPermissions.canManageEvents;
case 'manageScores':
return _currentUserPermissions.canManageScores;
case 'manageSettings':
return _currentUserPermissions.canManageSettings;
case 'manageSubscription':
return _currentUserPermissions.canManageSubscription;
case 'manageRoles':
return _currentUserPermissions.canManageRoles;
case 'viewFinancials':
return _currentUserPermissions.canViewFinancials;
case 'exportData':
return _currentUserPermissions.canExportData;
default:
return false;
}
}
}
+133
View File
@@ -0,0 +1,133 @@
import 'package:dio/dio.dart';
import 'package:cookie_jar/cookie_jar.dart';
import 'package:dio_cookie_manager/dio_cookie_manager.dart';
import 'package:flutter/material.dart';
import '../config/api_config.dart';
import '../services/auth_service.dart';
import '../screens/auth/login_screen.dart';
import '../main.dart'; // navigatorKey를 사용하기 위한 import
class ApiService {
static final ApiService _instance = ApiService._internal();
factory ApiService() => _instance;
late Dio _dio;
final CookieJar _cookieJar = CookieJar();
String? _token;
ApiService._internal() {
_dio = Dio(BaseOptions(
baseUrl: ApiConfig.baseUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 10),
contentType: 'application/json',
));
// 쿠키 관리자 설정
_dio.interceptors.add(CookieManager(_cookieJar));
// 로그 인터셉터 (디버깅용)
_dio.interceptors.add(LogInterceptor(
requestBody: true,
responseBody: true,
));
}
// 토큰 설정
void setToken(String token) {
_token = token;
_dio.options.headers['Authorization'] = 'Bearer $token';
}
// GET 요청
Future<dynamic> get(String path, {Map<String, dynamic>? queryParameters}) async {
try {
final response = await _dio.get(
path,
queryParameters: queryParameters,
);
return response.data;
} on DioException catch (e) {
_handleError(e);
rethrow;
}
}
// POST 요청
Future<dynamic> post(String path, {dynamic data}) async {
try {
final response = await _dio.post(
path,
data: data ?? {},
);
return response.data;
} on DioException catch (e) {
_handleError(e);
rethrow;
}
}
// PUT 요청
Future<dynamic> put(String path, {dynamic data}) async {
try {
final response = await _dio.put(
path,
data: data,
);
return response.data;
} on DioException catch (e) {
_handleError(e);
rethrow;
}
}
// DELETE 요청
Future<dynamic> delete(String path) async {
try {
final response = await _dio.delete(path);
return response.data;
} on DioException catch (e) {
_handleError(e);
rethrow;
}
}
// 에러 처리
void _handleError(DioException e) {
if (e.response != null) {
print('API 에러: ${e.response?.statusCode} - ${e.response?.data}');
// 401 오류 (인증 실패) 처리
if (e.response?.statusCode == 401) {
print('인증 오류: 토큰이 유효하지 않습니다. 로그아웃 처리합니다.');
_handleUnauthorized();
}
} else {
print('API 요청 에러: ${e.message}');
}
}
// 인증 오류 처리 (401)
void _handleUnauthorized() {
// 다음 프레임에서 실행하여 현재 API 호출 스택이 완료되도록 함
Future.microtask(() async {
try {
// AuthService 인스턴스 가져오기
final authService = AuthService();
// 로그아웃 처리
await authService.logout();
// 전역 네비게이터 키를 사용하여 로그인 화면으로 이동
if (navigatorKey.currentContext != null) {
Navigator.of(navigatorKey.currentContext!).pushAndRemoveUntil(
MaterialPageRoute(builder: (context) => const LoginScreen()),
(route) => false,
);
}
} catch (e) {
print('로그아웃 처리 중 오류 발생: $e');
}
});
}
}
+166
View File
@@ -0,0 +1,166 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../config/api_config.dart';
import '../models/user_model.dart';
import './api_service.dart';
class AuthService with ChangeNotifier {
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
User? _currentUser;
String? _token;
bool _isLoading = false;
bool _isInitialized = false;
final ApiService _apiService = ApiService();
User? get currentUser => _currentUser;
String? get token => _token;
bool get isLoading => _isLoading;
bool get isAuthenticated => _token != null;
bool get isInitialized => _isInitialized;
// 초기화 함수
Future<void> initialize() async {
_token = await _secureStorage.read(key: ApiConfig.tokenKey);
if (_token != null) {
await _fetchUserProfile();
}
_isInitialized = true;
notifyListeners();
}
// 로그인 함수
Future<bool> login(String username, String password) async {
_isLoading = true;
notifyListeners();
try {
final data = await _apiService.post(
ApiConfig.login,
data: {'username': username, 'password': password},
);
_token = data['token'];
_currentUser = User.fromJson(data['user']);
// 토큰 저장
await _secureStorage.write(key: ApiConfig.tokenKey, value: _token);
// API 서비스에 토큰 설정
_apiService.setToken(_token!);
// 사용자 ID와 역할 저장
final prefs = await SharedPreferences.getInstance();
await prefs.setString(ApiConfig.userIdKey, _currentUser!.id);
await prefs.setString(ApiConfig.userRoleKey, _currentUser!.role);
// 클럽 ID가 있으면 저장
if (_currentUser!.clubId != null) {
await prefs.setString(ApiConfig.clubIdKey, _currentUser!.clubId!);
}
_isLoading = false;
notifyListeners();
return true;
} catch (e) {
_isLoading = false;
notifyListeners();
return false;
}
}
// 로그아웃 함수
Future<void> logout() async {
_token = null;
_currentUser = null;
// 저장된 데이터 삭제
await _secureStorage.delete(key: ApiConfig.tokenKey);
final prefs = await SharedPreferences.getInstance();
await prefs.remove(ApiConfig.userIdKey);
await prefs.remove(ApiConfig.userRoleKey);
await prefs.remove(ApiConfig.clubIdKey);
notifyListeners();
}
// 사용자 프로필 정보 가져오기
Future<void> _fetchUserProfile() async {
if (_token == null) {
print('프로필 정보 가져오기 실패: 토큰이 없음');
return;
}
try {
print('프로필 정보 요청 시작: ${ApiConfig.profile}');
_apiService.setToken(_token!);
final data = await _apiService.get(ApiConfig.profile);
print('프로필 API 응답: $data');
if (data != null) {
try {
_currentUser = User.fromJson(data);
print('프로필 정보 파싱 성공: ${_currentUser?.name}');
notifyListeners();
} catch (e) {
print('프로필 정보 파싱 오류: $e');
print('파싱 실패한 데이터: $data');
}
} else {
print('프로필 정보 가져오기 실패: 응답 데이터가 null');
}
} catch (e) {
print('프로필 정보 가져오기 오류: $e');
}
}
// 회원가입 함수
Future<bool> register(String name, String email, String password) async {
_isLoading = true;
notifyListeners();
try {
final data = await _apiService.post(
ApiConfig.register,
data: {'name': name, 'email': email, 'password': password},
);
_isLoading = false;
notifyListeners();
if (data != null) {
return true;
} else {
return false;
}
} catch (e) {
_isLoading = false;
notifyListeners();
return false;
}
}
// 비밀번호 재설정 이메일 전송 함수
Future<bool> sendPasswordResetEmail(String email) async {
_isLoading = true;
notifyListeners();
try {
final data = await _apiService.post(
ApiConfig.forgotPassword,
data: {'email': email},
);
_isLoading = false;
notifyListeners();
return data != null;
} catch (e) {
_isLoading = false;
notifyListeners();
return false;
}
}
}
+208
View File
@@ -0,0 +1,208 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../config/api_config.dart';
import '../models/club_model.dart';
import './api_service.dart';
import './event_bus.dart';
class ClubService with ChangeNotifier {
List<Club> _clubs = [];
Club? _currentClub;
bool _isLoading = false;
String? _token;
final ApiService _apiService = ApiService();
List<Club> get clubs => [..._clubs];
Club? get currentClub => _currentClub;
bool get isLoading => _isLoading;
// 초기화 함수
Future<void> initialize(String token) async {
_token = token;
_apiService.setToken(token);
// 저장된 현재 클럽 ID 가져오기
final prefs = await SharedPreferences.getInstance();
final clubId = prefs.getString(ApiConfig.clubIdKey);
if (clubId != null) {
await fetchClubById(clubId);
}
}
// 사용자의 모든 클럽 가져오기
Future<void> fetchUserClubs() async {
if (_token == null) return;
_isLoading = true;
notifyListeners();
try {
final data = await _apiService.post('${ApiConfig.clubs}/user');
final List<dynamic> clubsData = data;
_clubs = clubsData.map((clubData) => Club.fromJson(clubData)).toList();
_isLoading = false;
notifyListeners();
} catch (e) {
_isLoading = false;
notifyListeners();
throw Exception('클럽 목록을 불러오는데 실패했습니다: $e');
}
}
// ID로 클럽 정보 가져오기
Future<void> fetchClubById(String clubId) async {
if (_token == null) return;
_isLoading = true;
notifyListeners();
try {
final data = await _apiService.post(
'${ApiConfig.clubs}',
data: {'clubId': clubId},
);
// 백엔드에서는 club 키로 감싸지 않고 직접 클럽 객체를 반환합니다
_currentClub = Club.fromJson(data);
// 현재 클럽 ID 저장
final prefs = await SharedPreferences.getInstance();
await prefs.setString(ApiConfig.clubIdKey, clubId);
_isLoading = false;
notifyListeners();
} catch (e) {
_isLoading = false;
notifyListeners();
throw Exception('클럽 정보를 불러오는데 실패했습니다: $e');
}
}
// 현재 클럽 설정
Future<void> setCurrentClub(String clubId) async {
await fetchClubById(clubId);
}
// 백엔드에 클럽 선택 요청 (세션에 clubId 저장)
Future<void> selectClub(String clubId) async {
if (_token == null) return;
_isLoading = true;
notifyListeners();
try {
await _apiService.post(
'${ApiConfig.clubs}/select-club',
data: {'clubId': clubId},
);
// 클럽 선택 성공 후 클럽 정보 가져오기
await fetchClubById(clubId);
// 클럽 ID를 로컬에 저장
final prefs = await SharedPreferences.getInstance();
await prefs.setString(ApiConfig.clubIdKey, clubId);
// 클럽 데이터도 함께 저장
if (_currentClub != null) {
await prefs.setString('${ApiConfig.clubIdKey}_data', jsonEncode(_currentClub!.toJson()));
}
// 클럽 변경 이벤트 발생 - 다른 서비스들에게 알림
EventBus().fire(ClubChangedEvent(clubId));
_isLoading = false;
notifyListeners();
} catch (e) {
_isLoading = false;
notifyListeners();
throw Exception('클럽 선택에 실패했습니다: $e');
}
}
// 클럽 생성
Future<Club> createClub(Club club) async {
if (_token == null) {
throw Exception('인증이 필요합니다');
}
_isLoading = true;
notifyListeners();
try {
final data = await _apiService.post(
'${ApiConfig.clubs}',
data: club.toJson(),
);
final newClub = Club.fromJson(data['club']);
_clubs.add(newClub);
_currentClub = newClub;
// 현재 클럽 ID 저장
final prefs = await SharedPreferences.getInstance();
await prefs.setString(ApiConfig.clubIdKey, newClub.id);
_isLoading = false;
notifyListeners();
return newClub;
} catch (e) {
_isLoading = false;
notifyListeners();
throw Exception('클럽 생성에 실패했습니다: $e');
}
}
// 클럽 정보 업데이트
Future<Club> updateClub(String clubId, Map<String, dynamic> updates) async {
if (_token == null) {
throw Exception('인증이 필요합니다');
}
_isLoading = true;
notifyListeners();
try {
final data = await _apiService.put(
ApiConfig.clubs,
data: {
'clubId': clubId,
...updates,
},
);
// 백엔드 응답 처리: 직접 클럽 객체가 반환되거나 {club: {...}} 형태로 반환될 수 있음
final updatedClub = data is Map && data.containsKey('club')
? Club.fromJson(data['club'])
: Club.fromJson(data);
// 클럽 목록 업데이트
final index = _clubs.indexWhere((club) => club.id == clubId);
if (index >= 0) {
_clubs[index] = updatedClub;
}
// 현재 클럽이 업데이트된 클럽인 경우 업데이트
if (_currentClub?.id == clubId) {
_currentClub = updatedClub;
}
_isLoading = false;
notifyListeners();
return updatedClub;
} catch (e) {
_isLoading = false;
notifyListeners();
throw Exception('클럽 정보 업데이트에 실패했습니다: $e');
}
}
}
+28
View File
@@ -0,0 +1,28 @@
import 'dart:async';
class EventBus {
static final EventBus _instance = EventBus._internal();
factory EventBus() => _instance;
EventBus._internal();
final _streamController = StreamController.broadcast();
Stream get stream => _streamController.stream;
void fire(dynamic event) {
_streamController.add(event);
}
void dispose() {
_streamController.close();
}
}
// 이벤트 클래스들
class ClubChangedEvent {
final String clubId;
ClubChangedEvent(this.clubId);
}
+185
View File
@@ -0,0 +1,185 @@
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../config/api_config.dart';
import '../models/event_model.dart';
import './api_service.dart';
class EventService with ChangeNotifier {
List<Event> _events = [];
Event? _currentEvent;
bool _isLoading = false;
String? _token;
final ApiService _apiService = ApiService();
String? _clubId;
List<Event> get events => [..._events];
bool get isLoading => _isLoading;
// 초기화 함수
Future<void> initialize(String token) async {
_token = token;
_apiService.setToken(token);
}
// 클럽 ID 설정
void setClubId(String clubId) {
_clubId = clubId;
notifyListeners();
}
// 클럽의 모든 이벤트 가져오기
Future<void> fetchClubEvents() async {
print('fetchClubEvents 호출됨: token=$_token');
_isLoading = true;
notifyListeners();
try {
// 클럽 ID 가져오기
final prefs = await SharedPreferences.getInstance();
final clubId = prefs.getString(ApiConfig.clubIdKey);
if (clubId == null) {
throw Exception('선택된 클럽이 없습니다');
}
final data = await _apiService.post(
'${ApiConfig.clubs}/events',
data: {'clubId': clubId},
);
// 응답 데이터가 리스트 형태로 직접 왔는지 확인
if (data is List) {
final List<dynamic> eventsData = data;
_events = eventsData
.map((eventData) => Event.fromJson(eventData))
.toList();
} else {
// 응답 데이터가 객체 형태로 왔을 경우 events 키를 통해 접근
final List<dynamic> eventsData = data['events'] ?? [];
_events = eventsData
.map((eventData) => Event.fromJson(eventData))
.toList();
}
_isLoading = false;
notifyListeners();
} catch (e) {
_isLoading = false;
notifyListeners();
throw Exception('이벤트 목록을 불러오는데 실패했습니다: $e');
}
}
// ID로 이벤트 정보 가져오기
Future<Event> fetchEventById(String eventId) async {
if (_token == null || _clubId == null) {
throw Exception('인증 또는 클럽 정보가 필요합니다');
}
try {
final data = await _apiService.post(
'${ApiConfig.clubs}/events/detail',
data: {'eventId': eventId},
);
return Event.fromJson(data['event']);
} catch (e) {
throw Exception('이벤트 정보를 불러오는데 실패했습니다: $e');
}
}
// 이벤트 생성
Future<Event> createEvent(Map<String, dynamic> eventData) async {
if (_token == null || _clubId == null) {
throw Exception('인증 또는 클럽 정보가 필요합니다');
}
_isLoading = true;
notifyListeners();
try {
final data = await _apiService.post(
'${ApiConfig.clubs}/events',
data: eventData,
);
final newEvent = Event.fromJson(data['event']);
_events.add(newEvent);
_isLoading = false;
notifyListeners();
return newEvent;
} catch (e) {
_isLoading = false;
notifyListeners();
throw Exception('이벤트 생성에 실패했습니다: $e');
}
}
// 이벤트 정보 업데이트
Future<Event> updateEvent(
String eventId,
Map<String, dynamic> updates,
) async {
if (_token == null || _clubId == null) {
throw Exception('인증 또는 클럽 정보가 필요합니다');
}
_isLoading = true;
notifyListeners();
try {
final data = await _apiService.put(
'${ApiConfig.clubs}/events/$eventId',
data: updates,
);
final updatedEvent = Event.fromJson(data['event']);
// 이벤트 목록 업데이트
final index = _events.indexWhere((event) => event.id == eventId);
if (index >= 0) {
_events[index] = updatedEvent;
}
_isLoading = false;
notifyListeners();
return updatedEvent;
} catch (e) {
_isLoading = false;
notifyListeners();
throw Exception('이벤트 정보 업데이트에 실패했습니다: $e');
}
}
// 이벤트 삭제
Future<bool> deleteEvent(String eventId) async {
if (_token == null || _clubId == null) {
throw Exception('인증 또는 클럽 정보가 필요합니다');
}
_isLoading = true;
notifyListeners();
try {
await _apiService.delete('${ApiConfig.clubs}/events/$eventId');
// 이벤트 목록에서 삭제
_events.removeWhere((event) => event.id == eventId);
_isLoading = false;
notifyListeners();
return true;
} catch (e) {
_isLoading = false;
notifyListeners();
throw Exception('이벤트 삭제에 실패했습니다: $e');
}
}
}
@@ -0,0 +1,284 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:in_app_purchase/in_app_purchase.dart';
import 'package:in_app_purchase_android/in_app_purchase_android.dart';
import 'package:in_app_purchase_storekit/in_app_purchase_storekit.dart';
import '../config/api_config.dart';
import '../models/subscription_model.dart';
/// 인앱 결제 서비스
class InAppPurchaseService extends ChangeNotifier {
final InAppPurchase _inAppPurchase = InAppPurchase.instance;
StreamSubscription<List<PurchaseDetails>>? _subscription;
List<ProductDetails> _products = [];
List<PurchaseDetails> _purchases = [];
bool _isAvailable = false;
bool _isLoading = false;
String? _error;
// 구매 검증 관련 변수
bool _purchaseVerified = false;
Map<String, dynamic>? _verifiedPurchase;
// 구독 상품 ID (스토어에 등록된 ID와 일치해야 함)
final Map<SubscriptionPlanType, String> _subscriptionIds = {
SubscriptionPlanType.basic: Platform.isAndroid
? 'com.bowlingmanager.subscription.basic'
: 'com.bowlingmanager.subscription.basic',
SubscriptionPlanType.premium: Platform.isAndroid
? 'com.bowlingmanager.subscription.premium'
: 'com.bowlingmanager.subscription.premium',
SubscriptionPlanType.enterprise: Platform.isAndroid
? 'com.bowlingmanager.subscription.enterprise'
: 'com.bowlingmanager.subscription.enterprise',
};
// 게터
List<ProductDetails> get products => _products;
List<PurchaseDetails> get purchases => _purchases;
bool get isAvailable => _isAvailable;
bool get isLoading => _isLoading;
String? get error => _error;
InAppPurchaseService() {
_initialize();
}
/// 초기화 및 결제 상태 리스너 설정
Future<void> _initialize() async {
_isLoading = true;
notifyListeners();
// 인앱 결제 가능 여부 확인
_isAvailable = await _inAppPurchase.isAvailable();
if (!_isAvailable) {
_error = '인앱 결제를 사용할 수 없습니다.';
_isLoading = false;
notifyListeners();
return;
}
// 결제 상태 변경 리스너 설정
final purchaseUpdated = _inAppPurchase.purchaseStream;
_subscription = purchaseUpdated.listen(
_onPurchaseUpdate,
onDone: _updateStreamOnDone,
onError: _updateStreamOnError,
);
// 상품 정보 로드
await _loadProducts();
_isLoading = false;
notifyListeners();
}
/// 상품 정보 로드
Future<void> _loadProducts() async {
try {
final Set<String> ids = _subscriptionIds.values.toSet();
final ProductDetailsResponse response =
await _inAppPurchase.queryProductDetails(ids);
if (response.notFoundIDs.isNotEmpty) {
_error = '일부 상품을 찾을 수 없습니다: ${response.notFoundIDs.join(", ")}';
}
_products = response.productDetails;
notifyListeners();
} catch (e) {
_error = '상품 정보를 불러오는데 실패했습니다: $e';
}
}
/// 구독 플랜 타입에 해당하는 상품 정보 가져오기
ProductDetails? getProductByPlanType(SubscriptionPlanType planType) {
final String? id = _subscriptionIds[planType];
if (id == null) return null;
try {
return _products.firstWhere((product) => product.id == id);
} catch (e) {
return null;
}
}
/// 구독 구매 시작
Future<bool> purchaseSubscription(SubscriptionPlanType planType) async {
final ProductDetails? product = getProductByPlanType(planType);
if (product == null) {
_error = '해당 구독 상품을 찾을 수 없습니다.';
notifyListeners();
return false;
}
try {
// 구매 요청
final PurchaseParam purchaseParam = PurchaseParam(
productDetails: product,
applicationUserName: null,
);
// 구독 상품 구매 요청
await _inAppPurchase.buyNonConsumable(purchaseParam: purchaseParam);
return true;
} catch (e) {
_error = '구독 구매 요청 중 오류가 발생했습니다: $e';
notifyListeners();
return false;
}
}
/// 구매 상태 업데이트 처리
void _onPurchaseUpdate(List<PurchaseDetails> purchaseDetailsList) async {
_purchases = purchaseDetailsList;
for (var purchaseDetails in purchaseDetailsList) {
if (purchaseDetails.status == PurchaseStatus.pending) {
// 결제 대기 중
_handlePendingPurchase(purchaseDetails);
} else if (purchaseDetails.status == PurchaseStatus.purchased ||
purchaseDetails.status == PurchaseStatus.restored) {
// 결제 완료 또는 복원됨
await _handleSuccessfulPurchase(purchaseDetails);
} else if (purchaseDetails.status == PurchaseStatus.error) {
// 결제 오류
_handleFailedPurchase(purchaseDetails);
} else if (purchaseDetails.status == PurchaseStatus.canceled) {
// 결제 취소
_handleCanceledPurchase(purchaseDetails);
}
// 구매 완료 처리 (iOS에서는 명시적으로 완료 처리 필요)
if (purchaseDetails.pendingCompletePurchase) {
await _inAppPurchase.completePurchase(purchaseDetails);
}
}
notifyListeners();
}
/// 결제 대기 중 처리
void _handlePendingPurchase(PurchaseDetails purchaseDetails) {
// 결제 대기 중 UI 업데이트 등 필요한 처리
}
/// 결제 성공 처리
Future<void> _handleSuccessfulPurchase(PurchaseDetails purchaseDetails) async {
// 서버에 구독 정보 업데이트 요청
await _verifyAndSavePurchase(purchaseDetails);
}
/// 결제 실패 처리
void _handleFailedPurchase(PurchaseDetails purchaseDetails) {
_error = '결제에 실패했습니다: ${purchaseDetails.error?.message ?? "알 수 없는 오류"}';
}
/// 결제 취소 처리
void _handleCanceledPurchase(PurchaseDetails purchaseDetails) {
_error = '결제가 취소되었습니다.';
}
/// 구매 검증 및 서버 저장
Future<bool> _verifyAndSavePurchase(PurchaseDetails purchaseDetails) async {
// 구매 영수증 정보 추출
String? receiptData;
String? transactionId = purchaseDetails.purchaseID;
String? productId = purchaseDetails.productID;
String platform = Platform.isIOS ? 'ios' : 'android';
if (Platform.isIOS) {
final InAppPurchaseStoreKitPlatformAddition iosPlatformAddition =
_inAppPurchase.getPlatformAddition<InAppPurchaseStoreKitPlatformAddition>();
receiptData = await iosPlatformAddition.retrieveReceiptData();
} else if (Platform.isAndroid) {
if (purchaseDetails is GooglePlayPurchaseDetails) {
receiptData = purchaseDetails.billingClientPurchase.originalJson;
}
}
if (receiptData == null) {
_error = '영수증 데이터를 추출할 수 없습니다.';
notifyListeners();
return false;
}
try {
// API 설정 가져오기
final String baseUrl = ApiConfig.baseUrl;
final String verifyEndpoint = ApiConfig.verifyReceipt;
// 서버에 영수증 검증 요청
final response = await http.post(
Uri.parse('$baseUrl$verifyEndpoint'),
headers: {
'Content-Type': 'application/json',
// 인증 토큰은 SubscriptionService에서 처리
},
body: jsonEncode({
'receipt': receiptData,
'transactionId': transactionId,
'productId': productId,
'platform': platform,
}),
);
if (response.statusCode == 200) {
// 검증 성공
final data = jsonDecode(response.body);
// 성공 이벤트 발생 (SubscriptionService에서 구독 정보 업데이트)
_purchaseVerified = true;
_verifiedPurchase = {
'transactionId': transactionId,
'productId': productId,
'verificationData': data,
};
notifyListeners();
return true;
} else {
// 검증 실패
_error = '영수증 검증에 실패했습니다. (${response.statusCode})';
notifyListeners();
return false;
}
} catch (e) {
_error = '영수증 검증 중 오류가 발생했습니다: $e';
notifyListeners();
return false;
}
}
/// 구독 복원
Future<bool> restorePurchases() async {
try {
await _inAppPurchase.restorePurchases();
return true;
} catch (e) {
_error = '구독 복원 중 오류가 발생했습니다: $e';
notifyListeners();
return false;
}
}
void _updateStreamOnDone() {
_subscription?.cancel();
}
void _updateStreamOnError(dynamic error) {
_error = '결제 스트림 오류: $error';
notifyListeners();
}
@override
void dispose() {
_subscription?.cancel();
super.dispose();
}
}
+229
View File
@@ -0,0 +1,229 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../config/api_config.dart';
import '../models/member_model.dart';
import './api_service.dart';
import './event_bus.dart';
class MemberService with ChangeNotifier {
List<Member> _members = [];
Member? _currentMember;
bool _isLoading = false;
String? _token;
String? _clubId;
final ApiService _apiService = ApiService();
StreamSubscription? _eventSubscription;
List<Member> get members => [..._members];
bool get isLoading => _isLoading;
// 초기화 함수
void initialize(String token) {
_token = token;
_apiService.setToken(token);
// 이벤트 구독
_eventSubscription = EventBus().stream.listen((event) {
if (event is ClubChangedEvent) {
setClubId(event.clubId);
}
});
}
@override
void dispose() {
_eventSubscription?.cancel();
super.dispose();
}
// 클럽 ID 설정
Future<void> setClubId(String clubId) async {
// 클럽 ID가 변경된 경우에만 처리
if (_clubId != clubId) {
_clubId = clubId;
notifyListeners();
// 새 클럽의 회원 목록 가져오기
if (_token != null) {
await fetchClubMembers();
}
}
}
// 클럽의 모든 회원 가져오기
Future<void> fetchClubMembers() async {
print('fetchClubMembers 호출됨: token=${_token}');
_isLoading = true;
notifyListeners();
try {
// 클럽 ID 가져오기
final prefs = await SharedPreferences.getInstance();
final clubId = prefs.getString(ApiConfig.clubIdKey);
if (clubId == null) {
throw Exception('선택된 클럽이 없습니다');
}
final data = await _apiService.post(
'${ApiConfig.clubs}/members',
data: {'clubId': clubId},
);
print('회원 응답 데이터: $data');
// 응답 데이터가 리스트 형태로 직접 왔으므로 그대로 사용
if (data is List) {
final List<dynamic> membersData = data;
_members = membersData
.map((memberData) => Member.fromJson(memberData))
.toList();
print('변환된 회원 리스트 길이: ${_members.length}');
} else {
// 응답 데이터가 객체 형태로 왔을 경우 members 키를 통해 접근
final List<dynamic> membersData = data['members'] ?? [];
_members = membersData
.map((memberData) => Member.fromJson(memberData))
.toList();
print('변환된 회원 리스트 길이: ${_members.length}');
}
_isLoading = false;
notifyListeners();
} catch (e) {
_isLoading = false;
notifyListeners();
throw Exception('회원 목록을 불러오는데 실패했습니다: $e');
}
}
// ID로 회원 정보 가져오기
Future<Member> fetchMemberById(String memberId) async {
if (_token == null || _clubId == null) {
throw Exception('인증 또는 클럽 정보가 필요합니다');
}
try {
final data = await _apiService.post(
'${ApiConfig.clubs}/members/detail',
data: {'memberId': memberId},
);
if (data['statusCode'] == 200) {
final memberData = data['member'];
return Member.fromJson(memberData);
} else {
throw Exception('회원 정보를 불러오는데 실패했습니다');
}
} catch (e) {
throw Exception('회원 정보를 불러오는데 실패했습니다: $e');
}
}
// 회원 추가
Future<Member> addMember(Map<String, dynamic> memberData) async {
if (_token == null || _clubId == null) {
throw Exception('인증 또는 클럽 정보가 필요합니다');
}
_isLoading = true;
notifyListeners();
try {
final data = await _apiService.post(
'${ApiConfig.clubs}/members/add',
data: memberData,
);
// 백엔드 응답 처리: 직접 회원 객체가 반환되거나 {member: {...}} 형태로 반환될 수 있음
final newMember = data is Map && data.containsKey('member')
? Member.fromJson(data['member'])
: Member.fromJson(data);
_members.add(newMember);
_isLoading = false;
notifyListeners();
return newMember;
} catch (e) {
_isLoading = false;
notifyListeners();
throw Exception('회원 추가에 실패했습니다: $e');
}
}
// 회원 정보 업데이트
Future<Member> updateMember(
String memberId,
Map<String, dynamic> updates,
) async {
if (_token == null || _clubId == null) {
throw Exception('인증 또는 클럽 정보가 필요합니다');
}
_isLoading = true;
notifyListeners();
try {
final data = await _apiService.post(
'${ApiConfig.clubs}/members/update',
data: {'clubId': _clubId, 'memberId': memberId, ...updates},
);
// 응답 데이터가 { member: {...} } 형태인지 또는 직접 회원 객체인지 확인
final Map<String, dynamic> memberData = data['member'] != null
? data['member'] as Map<String, dynamic>
: data as Map<String, dynamic>;
final updatedMember = Member.fromJson(memberData);
// 회원 목록 업데이트
final index = _members.indexWhere((member) => member.id == memberId);
if (index >= 0) {
_members[index] = updatedMember;
}
_isLoading = false;
notifyListeners();
return updatedMember;
} catch (e) {
_isLoading = false;
notifyListeners();
throw Exception('회원 정보 업데이트에 실패했습니다: $e');
}
}
// 회원 삭제 (또는 비활성화)
Future<bool> deleteMember(String memberId) async {
if (_token == null || _clubId == null) {
throw Exception('인증 또는 클럽 정보가 필요합니다');
}
_isLoading = true;
notifyListeners();
try {
await _apiService.post(
'${ApiConfig.clubs}/members/delete',
data: {'memberId': memberId, 'clubId': _clubId},
);
// 회원 목록에서 제거
_members.removeWhere((member) => member.id == memberId);
_isLoading = false;
notifyListeners();
return true;
} catch (e) {
_isLoading = false;
notifyListeners();
throw Exception('회원 삭제에 실패했습니다: $e');
}
}
}
+296
View File
@@ -0,0 +1,296 @@
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../config/api_config.dart';
import '../models/score_model.dart';
import './api_service.dart';
class ScoreService with ChangeNotifier {
List<Score> _scores = [];
bool _isLoading = false;
String? _token;
String? _clubId;
final ApiService _apiService = ApiService();
String? _memberId;
String? _eventId;
List<Score> get scores => [..._scores];
bool get isLoading => _isLoading;
// 초기화 함수
void initialize(String token) {
_token = token;
_apiService.setToken(token);
}
// 회원 ID 설정
void setMemberId(String memberId) {
_memberId = memberId;
notifyListeners();
}
// 이벤트 ID 설정
void setEventId(String eventId) {
_eventId = eventId;
notifyListeners();
}
// 클럽의 모든 점수 가져오기
Future<void> fetchClubScores() async {
print('fetchClubScores 호출됨: token=$_token');
_isLoading = true;
notifyListeners();
try {
// 클럽 ID 가져오기
final prefs = await SharedPreferences.getInstance();
final clubId = prefs.getString(ApiConfig.clubIdKey);
if (clubId == null) {
throw Exception('선택된 클럽이 없습니다');
}
final data = await _apiService.post(
'${ApiConfig.clubs}/scores',
data: {'clubId': clubId},
);
// 응답 데이터가 리스트 형태로 직접 왔는지 확인
if (data is List) {
final List<dynamic> scoresData = data;
_scores = scoresData
.map((scoreData) => Score.fromJson(scoreData))
.toList();
} else {
// 응답 데이터가 객체 형태로 왔을 경우 scores 키를 통해 접근
final List<dynamic> scoresData = data['scores'] ?? [];
_scores = scoresData
.map((scoreData) => Score.fromJson(scoreData))
.toList();
}
_isLoading = false;
notifyListeners();
} catch (e) {
_isLoading = false;
notifyListeners();
throw Exception('점수 목록을 불러오는데 실패했습니다: $e');
}
}
// 회원의 점수 가져오기
Future<List<Score>> fetchMemberScores(String memberId) async {
if (_token == null || _clubId == null) {
throw Exception('인증 또는 클럽 정보가 필요합니다');
}
_isLoading = true;
notifyListeners();
try {
final data = await _apiService.post(
'${ApiConfig.clubs}/members/scores',
data: {'memberId': memberId},
);
final List<dynamic> scoresData = data['scores'];
final memberScores = scoresData
.map((scoreData) => Score.fromJson(scoreData))
.toList();
_isLoading = false;
notifyListeners();
return memberScores;
} catch (e) {
_isLoading = false;
notifyListeners();
throw Exception('회원 점수를 불러오는데 실패했습니다: $e');
}
}
// 이벤트의 점수 가져오기
Future<List<Score>> fetchEventScores(String eventId) async {
if (_token == null || _clubId == null) {
throw Exception('인증 또는 클럽 정보가 필요합니다');
}
_isLoading = true;
notifyListeners();
try {
final data = await _apiService.post(
'${ApiConfig.clubs}/events/scores',
data: {'eventId': eventId},
);
final List<dynamic> scoresData = data['scores'];
final eventScores = scoresData
.map((scoreData) => Score.fromJson(scoreData))
.toList();
_isLoading = false;
notifyListeners();
return eventScores;
} catch (e) {
_isLoading = false;
notifyListeners();
throw Exception('이벤트 점수를 불러오는데 실패했습니다: $e');
}
}
// 점수 추가
Future<Score> addScore(Map<String, dynamic> scoreData) async {
if (_token == null || _clubId == null) {
throw Exception('인증 또는 클럽 정보가 필요합니다');
}
_isLoading = true;
notifyListeners();
try {
final data = await _apiService.post(ApiConfig.scores, data: scoreData);
final newScore = Score.fromJson(data['score']);
_scores.add(newScore);
_isLoading = false;
notifyListeners();
return newScore;
} catch (e) {
_isLoading = false;
notifyListeners();
throw Exception('점수 추가에 실패했습니다: $e');
}
}
// 점수 수정
Future<Score> updateScore(
String scoreId,
Map<String, dynamic> updates,
) async {
if (_token == null || _clubId == null) {
throw Exception('인증 또는 클럽 정보가 필요합니다');
}
_isLoading = true;
notifyListeners();
try {
final data = await _apiService.put(
'${ApiConfig.scores}/$scoreId',
data: updates,
);
final updatedScore = Score.fromJson(data['score']);
// 점수 목록 업데이트
final index = _scores.indexWhere((score) => score.id == scoreId);
if (index >= 0) {
_scores[index] = updatedScore;
}
_isLoading = false;
notifyListeners();
return updatedScore;
} catch (e) {
_isLoading = false;
notifyListeners();
throw Exception('점수 수정에 실패했습니다: $e');
}
}
// 점수 삭제
Future<bool> deleteScore(String scoreId) async {
if (_token == null || _clubId == null) {
throw Exception('인증 또는 클럽 정보가 필요합니다');
}
_isLoading = true;
notifyListeners();
try {
await _apiService.delete('${ApiConfig.scores}/$scoreId');
// 점수 목록에서 삭제
_scores.removeWhere((score) => score.id == scoreId);
_isLoading = false;
notifyListeners();
return true;
} catch (e) {
_isLoading = false;
notifyListeners();
throw Exception('점수 삭제에 실패했습니다: $e');
}
}
// 회원 통계 가져오기
Future<ScoreStatistics> fetchMemberStatistics(String memberId) async {
if (_token == null || _clubId == null) {
throw Exception('인증 또는 클럽 정보가 필요합니다');
}
try {
final data = await _apiService.post(
'${ApiConfig.clubs}/members/stats',
data: {'memberId': memberId},
);
return ScoreStatistics.fromJson(data['statistics']);
} catch (e) {
throw Exception('회원 통계를 불러오는데 실패했습니다: $e');
}
}
// 클럽 통계 가져오기
Future<Map<String, ScoreStatistics>> fetchClubStatistics() async {
if (_token == null) {
throw Exception('인증 정보가 필요합니다');
}
try {
// 클럽 ID 가져오기
final prefs = await SharedPreferences.getInstance();
final clubId = prefs.getString(ApiConfig.clubIdKey);
if (clubId == null) {
throw Exception('선택된 클럽이 없습니다');
}
final data = await _apiService.post(
'${ApiConfig.clubs}/scores/stats',
data: {'clubId': clubId},
);
final Map<String, ScoreStatistics> statistics = {};
if (data['statistics'] != null) {
final Map<String, dynamic> statsData =
data['statistics'] as Map<String, dynamic>;
statsData.forEach((key, value) {
if (value != null) {
try {
statistics[key] = ScoreStatistics.fromJson(value);
} catch (e) {
print('통계 변환 오류: $key - $e');
}
}
});
}
return statistics;
} catch (e) {
throw Exception('클럽 통계를 불러오는데 실패했습니다: $e');
}
}
}
@@ -0,0 +1,90 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../models/subscription_model.dart';
/// 구독 알림 서비스
class SubscriptionNotificationService extends ChangeNotifier {
// 구독 만료 임박 기준 (일)
static const int _expirationWarningDays = 7;
// 알림 상태
bool _hasExpirationWarning = false;
bool _hasExpired = false;
DateTime? _expirationDate;
Timer? _checkTimer;
// 게터
bool get hasExpirationWarning => _hasExpirationWarning;
bool get hasExpired => _hasExpired;
DateTime? get expirationDate => _expirationDate;
// 만료까지 남은 일수
int get daysUntilExpiration {
if (_expirationDate == null) return 0;
return _expirationDate!.difference(DateTime.now()).inDays;
}
// 만료일 포맷팅 (YYYY-MM-DD)
String get formattedExpirationDate {
if (_expirationDate == null) return '';
return DateFormat('yyyy-MM-dd').format(_expirationDate!);
}
SubscriptionNotificationService() {
// 주기적으로 구독 상태 확인 (1시간마다)
_checkTimer = Timer.periodic(const Duration(hours: 1), (_) {
_checkExpirationStatus();
});
}
/// 구독 정보 업데이트
void updateSubscription(Subscription? subscription) {
if (subscription == null || !subscription.isActive) {
_hasExpired = true;
_hasExpirationWarning = false;
_expirationDate = null;
} else {
_expirationDate = subscription.endDate;
_checkExpirationStatus();
}
notifyListeners();
}
/// 만료 상태 확인
void _checkExpirationStatus() {
if (_expirationDate == null) {
_hasExpired = true;
_hasExpirationWarning = false;
return;
}
final now = DateTime.now();
final daysLeft = _expirationDate!.difference(now).inDays;
// 만료 여부 확인
_hasExpired = now.isAfter(_expirationDate!);
// 만료 임박 여부 확인 (7일 이내)
_hasExpirationWarning = !_hasExpired && daysLeft <= _expirationWarningDays;
notifyListeners();
}
/// 알림 메시지 생성
String? getNotificationMessage() {
if (_hasExpired) {
return '구독이 만료되었습니다. 서비스 이용을 위해 구독을 갱신해 주세요.';
} else if (_hasExpirationWarning && _expirationDate != null) {
return '구독이 $daysUntilExpiration일 후 만료됩니다. 서비스 이용을 위해 구독을 갱신해 주세요.';
}
return null;
}
@override
void dispose() {
_checkTimer?.cancel();
super.dispose();
}
}
@@ -0,0 +1,445 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import '../config/api_config.dart';
import '../models/subscription_model.dart';
import 'in_app_purchase_service.dart';
/// 구독 관련 서비스
class SubscriptionService extends ChangeNotifier {
final String _baseUrl = ApiConfig.baseUrl;
final String _token;
Subscription? _currentSubscription;
List<SubscriptionPlan> _availablePlans = [];
bool _isLoading = false;
String? _error;
// 인앱 결제 서비스
final InAppPurchaseService _purchaseService = InAppPurchaseService();
SubscriptionService(this._token) {
_loadAvailablePlans();
_initializeInAppPurchase();
}
// 게터
Subscription? get currentSubscription => _currentSubscription;
List<SubscriptionPlan> get availablePlans => _availablePlans;
bool get isLoading => _isLoading || _purchaseService.isLoading;
String? get error => _error ?? _purchaseService.error;
bool get hasSubscription => _currentSubscription != null;
bool get isActive => _currentSubscription?.isActive ?? false;
// 인앱 결제 관련 게터
InAppPurchaseService get purchaseService => _purchaseService;
List<ProductDetails> get products => _purchaseService.products;
/// 현재 구독 정보 로드
Future<void> loadCurrentSubscription() async {
_isLoading = true;
_error = null;
notifyListeners();
try {
final response = await http.post(
Uri.parse('$_baseUrl${ApiConfig.currentSubscription}'),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $_token',
},
body: jsonEncode({
'clubId': _clubId,
}),
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
_currentSubscription = Subscription.fromJson(data);
} else if (response.statusCode == 404) {
// 구독 정보가 없는 경우 (Free 플랜으로 간주)
_currentSubscription = Subscription(
id: 'free',
userId: _userId,
planType: SubscriptionPlanType.free,
status: SubscriptionStatus.active,
startDate: DateTime.now(),
endDate: DateTime.now().add(const Duration(days: 36500)), // 100년
autoRenew: false,
lastPaymentDate: null,
nextPaymentDate: null,
);
} else {
_error = '구독 정보를 불러오는데 실패했습니다. (${response.statusCode})';
}
} catch (e) {
_error = '구독 정보를 불러오는데 실패했습니다: $e';
} finally {
_isLoading = false;
notifyListeners();
}
}
/// 사용 가능한 구독 플랜 목록 로드
Future<void> _loadAvailablePlans() async {
_isLoading = true;
_error = null;
notifyListeners();
try {
final response = await http.get(
Uri.parse('$_baseUrl${ApiConfig.subscriptionPlans}'),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $_token',
},
);
if (response.statusCode == 200) {
final List<dynamic> data = jsonDecode(response.body);
_availablePlans = data.map((plan) => SubscriptionPlan.fromJson(plan)).toList();
} else {
_error = '구독 플랜 정보를 불러오는데 실패했습니다. (${response.statusCode})';
// 기본 플랜 정보 사용
_availablePlans = SubscriptionPlan.getDefaultPlans();
}
} catch (e) {
_error = '구독 플랜 정보를 불러오는데 실패했습니다: $e';
// 기본 플랜 정보 사용
_availablePlans = SubscriptionPlan.getDefaultPlans();
} finally {
_isLoading = false;
notifyListeners();
}
}
/// 인앱 결제 서비스 초기화
Future<void> _initializeInAppPurchase() async {
// 인앱 결제 서비스는 자체적으로 초기화됨
// 필요한 경우 추가 설정
}
/// 구독 복원
Future<bool> restoreSubscriptions() async {
_isLoading = true;
_error = null;
notifyListeners();
try {
// 1. 인앱 결제 복원 요청
final restoreSuccess = await _purchaseService.restorePurchases();
if (!restoreSuccess) {
_error = '구독 복원에 실패했습니다.';
return false;
}
// 2. 서버에서 최신 구독 정보 조회
await fetchCurrentSubscription();
return true;
} catch (e) {
_error = '구독 복원에 실패했습니다: $e';
return false;
} finally {
_isLoading = false;
notifyListeners();
}
}
/// 구독 상태 검증
Future<bool> verifySubscription() async {
_isLoading = true;
_error = null;
notifyListeners();
try {
final response = await http.get(
Uri.parse('$_baseUrl/api/subscriptions/verify'),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $_token',
},
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
_currentSubscription = Subscription.fromJson(data);
notifyListeners();
return true;
} else {
_error = '구독 검증에 실패했습니다. (${response.statusCode})';
return false;
}
} catch (e) {
_error = '구독 검증에 실패했습니다: $e';
return false;
} finally {
_isLoading = false;
notifyListeners();
}
}
/// 새 구독 생성 (인앱 결제 통합)
Future<bool> createSubscription(SubscriptionPlanType planType, bool isYearly) async {
_isLoading = true;
_error = null;
notifyListeners();
try {
// 인앱 결제 시작
final success = await _purchaseService.purchaseSubscription(planType);
if (!success) {
_error = '구독 구매 요청에 실패했습니다.';
_isLoading = false;
notifyListeners();
return false;
}
// 구매 검증 결과 대기
bool verified = false;
int attempts = 0;
const maxAttempts = 10;
// 구매 검증이 완료될 때까지 최대 10회 확인
while (!verified && attempts < maxAttempts) {
await Future.delayed(const Duration(seconds: 1));
verified = _purchaseService._purchaseVerified;
attempts++;
// 오류가 발생한 경우
if (_purchaseService.error != null) {
_error = _purchaseService.error;
_isLoading = false;
notifyListeners();
return false;
}
}
if (!verified) {
_error = '구독 검증 시간이 초과되었습니다.';
_isLoading = false;
notifyListeners();
return false;
}
// 검증된 구매 정보 가져오기
final verifiedPurchase = _purchaseService._verifiedPurchase;
if (verifiedPurchase == null) {
_error = '검증된 구매 정보를 찾을 수 없습니다.';
_isLoading = false;
notifyListeners();
return false;
}
// 서버에 구독 생성 요청
final response = await http.post(
Uri.parse('$_baseUrl${ApiConfig.subscribeClub}'),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $_token',
},
body: jsonEncode({
'planType': planType.toString().split('.').last,
'isYearly': isYearly,
'clubId': _clubId,
'transactionId': verifiedPurchase['transactionId'],
'productId': verifiedPurchase['productId'],
'verificationData': verifiedPurchase['verificationData'],
}),
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
_currentSubscription = Subscription.fromJson(data);
// 구매 검증 상태 초기화
_purchaseService._purchaseVerified = false;
_purchaseService._verifiedPurchase = null;
notifyListeners();
return true;
} else {
_error = '구독 생성에 실패했습니다. (${response.statusCode})';
return false;
}
} catch (e) {
_error = '구독 생성 중 오류가 발생했습니다: $e';
return false;
} finally {
_isLoading = false;
notifyListeners();
}
}
/// 구독 취소
Future<bool> cancelSubscription() async {
if (_currentSubscription == null) {
_error = '취소할 구독이 없습니다.';
notifyListeners();
return false;
}
_isLoading = true;
_error = null;
notifyListeners();
try {
final response = await http.post(
Uri.parse('$_baseUrl/api/subscriptions/${_currentSubscription!.id}/cancel'),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $_token',
},
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
_currentSubscription = Subscription.fromJson(data);
notifyListeners();
return true;
} else {
_error = '구독 취소에 실패했습니다. (${response.statusCode})';
return false;
}
} catch (e) {
_error = '구독 취소에 실패했습니다: $e';
return false;
} finally {
_isLoading = false;
notifyListeners();
}
}
/// 구독 플랜 변경
Future<bool> changePlan(SubscriptionPlanType newPlanType, bool isYearly) async {
if (_currentSubscription == null) {
_error = '변경할 구독이 없습니다.';
notifyListeners();
return false;
}
_isLoading = true;
_error = null;
notifyListeners();
try {
final response = await http.post(
Uri.parse('$_baseUrl${ApiConfig.changePlan}'),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $_token',
},
body: jsonEncode({
'subscriptionId': _currentSubscription!.id,
'planType': newPlanType.toString().split('.').last,
'isYearly': isYearly,
'clubId': _clubId,
}),
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
_currentSubscription = Subscription.fromJson(data);
notifyListeners();
return true;
} else {
_error = '구독 플랜 변경에 실패했습니다. (${response.statusCode})';
return false;
}
} catch (e) {
_error = '구독 플랜 변경에 실패했습니다: $e';
return false;
} finally {
_isLoading = false;
notifyListeners();
}
}
/// 자동 갱신 설정 변경
Future<bool> toggleAutoRenew() async {
if (_currentSubscription == null) {
_error = '변경할 구독이 없습니다.';
notifyListeners();
return false;
}
_isLoading = true;
_error = null;
notifyListeners();
try {
final response = await http.post(
Uri.parse('$_baseUrl${ApiConfig.subscriptions}/autorenew'),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $_token',
},
body: jsonEncode({
'subscriptionId': _currentSubscription!.id,
'autoRenew': !_currentSubscription!.autoRenew,
'clubId': _clubId,
}),
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
_currentSubscription = Subscription.fromJson(data);
notifyListeners();
return true;
} else {
_error = '자동 갱신 설정 변경에 실패했습니다. (${response.statusCode})';
return false;
}
} catch (e) {
_error = '자동 갱신 설정 변경에 실패했습니다: $e';
return false;
} finally {
_isLoading = false;
notifyListeners();
}
}
/// 특정 기능이 현재 구독에서 사용 가능한지 확인
bool canUseFeature(String featureName) {
if (_currentSubscription == null || !_currentSubscription!.isActive) {
return false;
}
// 기능별 접근 권한 확인 로직
switch (featureName) {
case 'advancedStats':
return _getSubscriptionPlan(_currentSubscription!.planType).hasAdvancedStats;
case 'customization':
return _getSubscriptionPlan(_currentSubscription!.planType).hasCustomization;
case 'prioritySupport':
return _getSubscriptionPlan(_currentSubscription!.planType).hasPriority;
default:
return false;
}
}
/// 현재 구독으로 추가 가능한 최대 회원 수 확인
int getMaxMembers() {
if (_currentSubscription == null || !_currentSubscription!.isActive) {
return SubscriptionPlan.getDefaultPlans().first.maxMembers; // 무료 플랜
}
return _getSubscriptionPlan(_currentSubscription!.planType).maxMembers;
}
/// 현재 구독으로 추가 가능한 최대 이벤트 수 확인
int getMaxEvents() {
if (_currentSubscription == null || !_currentSubscription!.isActive) {
return SubscriptionPlan.getDefaultPlans().first.maxEvents; // 무료 플랜
}
return _getSubscriptionPlan(_currentSubscription!.planType).maxEvents;
}
/// 구독 플랜 타입에 해당하는 플랜 정보 가져오기
SubscriptionPlan _getSubscriptionPlan(SubscriptionPlanType planType) {
return _availablePlans.firstWhere(
(plan) => plan.type == planType,
orElse: () => SubscriptionPlan.getDefaultPlans().first,
);
}
}
+110
View File
@@ -0,0 +1,110 @@
// 역할 옵션
const Map<String, String> roleOptions = {
'admin': '관리자',
'clubadmin': '클럽 관리자',
'user': '일반 사용자',
};
// 성별 옵션
const Map<String, String> genderOptions = {
'male': '남성',
'female': '여성',
};
// 회원 유형 옵션
const Map<String, String> memberTypeOptions = {
'regular': '정회원',
'associate': '준회원',
'guest': '게스트',
'manager': '운영진',
'owner': '모임장',
};
// 상태 옵션
const Map<String, String> statusOptions = {
'active': '활성',
'inactive': '비활성',
'suspended': '정지',
'deleted': '삭제',
};
// 참가자 상태 옵션
const Map<String, String> participantStatusOptions = {
'pending': '참가예정',
'confirmed': '참가확정',
'canceled': '취소',
};
// 이벤트 상태 옵션
const Map<String, String> eventStatusOptions = {
'ready': '준비',
'active': '활성',
'completed': '완료',
'canceled': '취소',
'deleted': '삭제',
};
// 이벤트 유형 옵션
const Map<String, String> eventTypeOptions = {
'regular': '정기전',
'exchange': '교류전',
'tournament': '토너먼트',
'lightning': '번개',
'friendly': '친선전',
'other': '기타',
};
// 결제 상태 옵션
const Map<String, String> paymentStatusOptions = {
'unpaid': '미납',
'paid': '납부완료',
};
// 평균 산정 기준 옵션
const Map<String, String> averageCalculationPeriodOptions = {
'total': '전체',
'1year': '최근 1년',
'6month': '최근 6개월',
'3month': '최근 3개월',
'firsthalf': '상반기',
'secondhalf': '하반기',
'quarterly': '분기별',
};
// 회원 유형 레이블 가져오기
String getMemberTypeLabel(String? memberType) {
if (memberType == null) return '일반 회원';
return memberTypeOptions[memberType] ?? memberType;
}
// 역할 레이블 가져오기
String getRoleLabel(String? role) {
if (role == null) return '일반 사용자';
return roleOptions[role] ?? role;
}
// 성별 레이블 가져오기
String getGenderLabel(String? gender) {
if (gender == null) return '정보 없음';
return genderOptions[gender] ?? gender;
}
// 상태 레이블 가져오기
String getStatusLabel(String? status) {
if (status == null) return '정보 없음';
return statusOptions[status] ?? status;
}
// 이벤트 상태 레이블 가져오기
String getEventStatusLabel(String? status) {
if (status == null) return '정보 없음';
return eventStatusOptions[status] ?? status;
}
// 이벤트 유형 레이블 가져오기
String getEventTypeLabel(String? type) {
if (type == null) return '기타';
return eventTypeOptions[type] ?? type;
}
@@ -0,0 +1,90 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/subscription_notification_service.dart';
import '../services/subscription_service.dart';
/// 구독 알림 위젯
class SubscriptionAlertWidget extends StatelessWidget {
const SubscriptionAlertWidget({super.key});
@override
Widget build(BuildContext context) {
// 구독 알림 서비스 가져오기
final notificationService = Provider.of<SubscriptionNotificationService>(
context,
listen: true,
);
// 구독 서비스 가져오기
final subscriptionService = Provider.of<SubscriptionService>(
context,
listen: false,
);
// 알림 메시지 가져오기
final message = notificationService.getNotificationMessage();
// 알림이 없으면 빈 컨테이너 반환
if (message == null) {
return const SizedBox.shrink();
}
// 알림 배너 표시
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0),
color: notificationService.hasExpired
? Colors.red.shade100
: Colors.orange.shade100,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
notificationService.hasExpired
? Icons.error_outline
: Icons.warning_amber_outlined,
color: notificationService.hasExpired
? Colors.red.shade700
: Colors.orange.shade700,
),
const SizedBox(width: 8),
Expanded(
child: Text(
message,
style: TextStyle(
color: notificationService.hasExpired
? Colors.red.shade700
: Colors.orange.shade700,
fontWeight: FontWeight.bold,
),
),
),
],
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () {
// 구독 화면으로 이동
Navigator.of(context).pushNamed('/subscription');
},
style: TextButton.styleFrom(
backgroundColor: notificationService.hasExpired
? Colors.red.shade700
: Colors.orange.shade700,
foregroundColor: Colors.white,
),
child: const Text('구독 갱신하기'),
),
],
),
],
),
);
}
}