Line data Source code
1 : import 'dart:async';
2 : import 'dart:convert';
3 : import 'dart:io';
4 : import 'package:flutter/material.dart';
5 : import 'package:http/http.dart' as http;
6 : import 'package:in_app_purchase/in_app_purchase.dart';
7 : import 'package:in_app_purchase_android/in_app_purchase_android.dart';
8 : import 'package:in_app_purchase_storekit/in_app_purchase_storekit.dart';
9 :
10 : import '../config/api_config.dart';
11 : import '../models/subscription_model.dart';
12 :
13 : /// 인앱 결제 서비스
14 : class InAppPurchaseService extends ChangeNotifier {
15 : final InAppPurchase _inAppPurchase = InAppPurchase.instance;
16 : StreamSubscription<List<PurchaseDetails>>? _subscription;
17 :
18 : List<ProductDetails> _products = [];
19 : List<PurchaseDetails> _purchases = [];
20 : bool _isAvailable = false;
21 : bool _isLoading = false;
22 : String? _error;
23 :
24 : // 구매 검증 관련 변수
25 : bool _purchaseVerified = false;
26 : Map<String, dynamic>? _verifiedPurchase;
27 :
28 : // 구독 상품 ID (스토어에 등록된 ID와 일치해야 함)
29 : final Map<SubscriptionPlanType, String> _subscriptionIds = {
30 : SubscriptionPlanType.basic: Platform.isAndroid
31 : ? 'com.bowlingmanager.subscription.basic'
32 : : 'com.bowlingmanager.subscription.basic',
33 : SubscriptionPlanType.premium: Platform.isAndroid
34 : ? 'com.bowlingmanager.subscription.premium'
35 : : 'com.bowlingmanager.subscription.premium',
36 : SubscriptionPlanType.enterprise: Platform.isAndroid
37 : ? 'com.bowlingmanager.subscription.enterprise'
38 : : 'com.bowlingmanager.subscription.enterprise',
39 : };
40 :
41 : // 게터
42 0 : List<ProductDetails> get products => _products;
43 0 : List<PurchaseDetails> get purchases => _purchases;
44 0 : bool get isAvailable => _isAvailable;
45 0 : bool get isLoading => _isLoading;
46 0 : String? get error => _error;
47 :
48 : // 구매 검증 관련 게터/세터
49 0 : bool get isPurchaseVerified => _purchaseVerified;
50 0 : set isPurchaseVerified(bool value) => _purchaseVerified = value;
51 0 : PurchaseDetails? get lastVerifiedPurchase => _purchases.isNotEmpty ? _purchases.last : null;
52 0 : set lastVerifiedPurchase(PurchaseDetails? value) {
53 0 : if (value != null && !_purchases.contains(value)) {
54 0 : _purchases.add(value);
55 : }
56 : }
57 :
58 0 : InAppPurchaseService() {
59 0 : _initialize();
60 : }
61 :
62 : /// 초기화 및 결제 상태 리스너 설정
63 0 : Future<void> _initialize() async {
64 0 : _isLoading = true;
65 0 : notifyListeners();
66 :
67 : // 인앱 결제 가능 여부 확인
68 0 : _isAvailable = await _inAppPurchase.isAvailable();
69 :
70 0 : if (!_isAvailable) {
71 0 : _error = '인앱 결제를 사용할 수 없습니다.';
72 0 : _isLoading = false;
73 0 : notifyListeners();
74 : return;
75 : }
76 :
77 : // 결제 상태 변경 리스너 설정
78 0 : final purchaseUpdated = _inAppPurchase.purchaseStream;
79 0 : _subscription = purchaseUpdated.listen(
80 0 : _onPurchaseUpdate,
81 0 : onDone: _updateStreamOnDone,
82 0 : onError: _updateStreamOnError,
83 : );
84 :
85 : // 상품 정보 로드
86 0 : await _loadProducts();
87 :
88 0 : _isLoading = false;
89 0 : notifyListeners();
90 : }
91 :
92 : /// 상품 정보 로드
93 0 : Future<void> _loadProducts() async {
94 : try {
95 0 : final Set<String> ids = _subscriptionIds.values.toSet();
96 : final ProductDetailsResponse response =
97 0 : await _inAppPurchase.queryProductDetails(ids);
98 :
99 0 : if (response.notFoundIDs.isNotEmpty) {
100 0 : _error = '일부 상품을 찾을 수 없습니다: ${response.notFoundIDs.join(", ")}';
101 : }
102 :
103 0 : _products = response.productDetails;
104 0 : notifyListeners();
105 : } catch (e) {
106 0 : _error = '상품 정보를 불러오는데 실패했습니다: $e';
107 : }
108 : }
109 :
110 : /// 구독 플랜 타입에 해당하는 상품 정보 가져오기
111 0 : ProductDetails? getProductByPlanType(SubscriptionPlanType planType) {
112 0 : final String? id = _subscriptionIds[planType];
113 : if (id == null) return null;
114 :
115 : try {
116 0 : return _products.firstWhere((product) => product.id == id);
117 : } catch (e) {
118 : return null;
119 : }
120 : }
121 :
122 : /// 구독 구매 시작
123 0 : Future<bool> purchaseSubscription(SubscriptionPlanType planType) async {
124 0 : final ProductDetails? product = getProductByPlanType(planType);
125 :
126 : if (product == null) {
127 0 : _error = '해당 구독 상품을 찾을 수 없습니다.';
128 0 : notifyListeners();
129 : return false;
130 : }
131 :
132 : try {
133 : // 구매 요청
134 0 : final PurchaseParam purchaseParam = PurchaseParam(
135 : productDetails: product,
136 : applicationUserName: null,
137 : );
138 :
139 : // 구독 상품 구매 요청
140 0 : await _inAppPurchase.buyNonConsumable(purchaseParam: purchaseParam);
141 : return true;
142 : } catch (e) {
143 0 : _error = '구독 구매 요청 중 오류가 발생했습니다: $e';
144 0 : notifyListeners();
145 : return false;
146 : }
147 : }
148 :
149 : /// 구매 상태 업데이트 처리
150 0 : void _onPurchaseUpdate(List<PurchaseDetails> purchaseDetailsList) async {
151 0 : _purchases = purchaseDetailsList;
152 :
153 0 : for (var purchaseDetails in purchaseDetailsList) {
154 0 : if (purchaseDetails.status == PurchaseStatus.pending) {
155 : // 결제 대기 중
156 0 : _handlePendingPurchase(purchaseDetails);
157 0 : } else if (purchaseDetails.status == PurchaseStatus.purchased ||
158 0 : purchaseDetails.status == PurchaseStatus.restored) {
159 : // 결제 완료 또는 복원됨
160 0 : await _handleSuccessfulPurchase(purchaseDetails);
161 0 : } else if (purchaseDetails.status == PurchaseStatus.error) {
162 : // 결제 오류
163 0 : _handleFailedPurchase(purchaseDetails);
164 0 : } else if (purchaseDetails.status == PurchaseStatus.canceled) {
165 : // 결제 취소
166 0 : _handleCanceledPurchase(purchaseDetails);
167 : }
168 :
169 : // 구매 완료 처리 (iOS에서는 명시적으로 완료 처리 필요)
170 0 : if (purchaseDetails.pendingCompletePurchase) {
171 0 : await _inAppPurchase.completePurchase(purchaseDetails);
172 : }
173 : }
174 :
175 0 : notifyListeners();
176 : }
177 :
178 : /// 결제 대기 중 처리
179 0 : void _handlePendingPurchase(PurchaseDetails purchaseDetails) {
180 : // 결제 대기 중 UI 업데이트 등 필요한 처리
181 : }
182 :
183 : /// 결제 성공 처리
184 0 : Future<void> _handleSuccessfulPurchase(PurchaseDetails purchaseDetails) async {
185 : // 서버에 구독 정보 업데이트 요청
186 0 : await _verifyAndSavePurchase(purchaseDetails);
187 : }
188 :
189 : /// 결제 실패 처리
190 0 : void _handleFailedPurchase(PurchaseDetails purchaseDetails) {
191 0 : _error = '결제에 실패했습니다: ${purchaseDetails.error?.message ?? "알 수 없는 오류"}';
192 : }
193 :
194 : /// 결제 취소 처리
195 0 : void _handleCanceledPurchase(PurchaseDetails purchaseDetails) {
196 0 : _error = '결제가 취소되었습니다.';
197 : }
198 :
199 : /// 구매 검증 및 서버 저장
200 0 : Future<bool> _verifyAndSavePurchase(PurchaseDetails purchaseDetails) async {
201 : // 구매 영수증 정보 추출
202 : String? receiptData;
203 0 : String? transactionId = purchaseDetails.purchaseID;
204 0 : String? productId = purchaseDetails.productID;
205 0 : String platform = Platform.isIOS ? 'ios' : 'android';
206 :
207 0 : if (Platform.isIOS) {
208 : // iOS의 경우 구매 정보에서 직접 가져오기
209 0 : receiptData = purchaseDetails.verificationData.serverVerificationData;
210 0 : } else if (Platform.isAndroid) {
211 0 : if (purchaseDetails is GooglePlayPurchaseDetails) {
212 0 : receiptData = purchaseDetails.billingClientPurchase.originalJson;
213 : }
214 : }
215 :
216 : if (receiptData == null) {
217 0 : _error = '영수증 데이터를 추출할 수 없습니다.';
218 0 : notifyListeners();
219 : return false;
220 : }
221 :
222 : try {
223 : // API 설정 가져오기
224 : final String baseUrl = ApiConfig.baseUrl;
225 : final String verifyEndpoint = ApiConfig.verifyReceipt;
226 :
227 : // 서버에 영수증 검증 요청
228 0 : final response = await http.post(
229 0 : Uri.parse('$baseUrl$verifyEndpoint'),
230 0 : headers: {
231 : 'Content-Type': 'application/json',
232 : // 인증 토큰은 SubscriptionService에서 처리
233 : },
234 0 : body: jsonEncode({
235 : 'receipt': receiptData,
236 : 'transactionId': transactionId,
237 : 'productId': productId,
238 : 'platform': platform,
239 : }),
240 : );
241 :
242 0 : if (response.statusCode == 200) {
243 : // 검증 성공
244 0 : final data = jsonDecode(response.body);
245 : // 성공 이벤트 발생 (SubscriptionService에서 구독 정보 업데이트)
246 0 : _purchaseVerified = true;
247 0 : _verifiedPurchase = {
248 : 'transactionId': transactionId,
249 : 'productId': productId,
250 : 'verificationData': data,
251 : };
252 0 : notifyListeners();
253 : return true;
254 : } else {
255 : // 검증 실패
256 0 : _error = '영수증 검증에 실패했습니다. (${response.statusCode})';
257 0 : notifyListeners();
258 : return false;
259 : }
260 : } catch (e) {
261 0 : _error = '영수증 검증 중 오류가 발생했습니다: $e';
262 0 : notifyListeners();
263 : return false;
264 : }
265 : }
266 :
267 : /// 구독 복원
268 0 : Future<bool> restorePurchases() async {
269 : try {
270 0 : await _inAppPurchase.restorePurchases();
271 : return true;
272 : } catch (e) {
273 0 : _error = '구독 복원 중 오류가 발생했습니다: $e';
274 0 : notifyListeners();
275 : return false;
276 : }
277 : }
278 :
279 0 : void _updateStreamOnDone() {
280 0 : _subscription?.cancel();
281 : }
282 :
283 0 : void _updateStreamOnError(dynamic error) {
284 0 : _error = '결제 스트림 오류: $error';
285 0 : notifyListeners();
286 : }
287 :
288 0 : @override
289 : void dispose() {
290 0 : _subscription?.cancel();
291 0 : super.dispose();
292 : }
293 : }
|