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