294 lines
9.2 KiB
Dart
294 lines
9.2 KiB
Dart
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;
|
|
|
|
// 구매 검증 관련 게터/세터
|
|
bool get isPurchaseVerified => _purchaseVerified;
|
|
set isPurchaseVerified(bool value) => _purchaseVerified = value;
|
|
PurchaseDetails? get lastVerifiedPurchase => _purchases.isNotEmpty ? _purchases.last : null;
|
|
set lastVerifiedPurchase(PurchaseDetails? value) {
|
|
if (value != null && !_purchases.contains(value)) {
|
|
_purchases.add(value);
|
|
}
|
|
}
|
|
|
|
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) {
|
|
// iOS의 경우 구매 정보에서 직접 가져오기
|
|
receiptData = purchaseDetails.verificationData.serverVerificationData;
|
|
} 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();
|
|
}
|
|
}
|