회원목록
This commit is contained in:
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user