diff --git a/lib/src/core/engine/iap_service.dart b/lib/src/core/engine/iap_service.dart new file mode 100644 index 0000000..f0caa76 --- /dev/null +++ b/lib/src/core/engine/iap_service.dart @@ -0,0 +1,341 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:in_app_purchase/in_app_purchase.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// IAP 상품 ID +class IAPProductIds { + IAPProductIds._(); + + /// 광고 제거 상품 ID (비소모성) + /// TODO: Google Play Console / App Store Connect에서 상품 생성 후 ID 교체 + static const String removeAds = 'remove_ads'; + + /// 모든 상품 ID 목록 + static const Set all = {removeAds}; +} + +/// IAP 구매 결과 +enum IAPResult { + /// 구매 성공 + success, + + /// 구매 취소 + cancelled, + + /// 구매 실패 + failed, + + /// 이미 구매됨 + alreadyPurchased, + + /// 상품을 찾을 수 없음 + productNotFound, + + /// 스토어 사용 불가 + storeUnavailable, + + /// 디버그 모드에서 시뮬레이션 + debugSimulated, +} + +/// IAP 서비스 +/// +/// 인앱 구매 (광고 제거) 처리를 담당합니다. +/// shared_preferences를 사용하여 구매 상태를 영구 저장합니다. +class IAPService { + IAPService._(); + + static IAPService? _instance; + + /// 싱글톤 인스턴스 + static IAPService get instance { + _instance ??= IAPService._(); + return _instance!; + } + + // =========================================================================== + // 상수 + // =========================================================================== + + /// 구매 상태 저장 키 + static const String _purchaseKey = 'iap_remove_ads_purchased'; + + // =========================================================================== + // 상태 + // =========================================================================== + + final InAppPurchase _iap = InAppPurchase.instance; + + bool _isInitialized = false; + bool _isAvailable = false; + + /// 상품 정보 + ProductDetails? _removeAdsProduct; + + /// 구매 스트림 구독 + StreamSubscription>? _subscription; + + /// 광고 제거 구매 여부 (캐시) + bool _adRemovalPurchased = false; + + /// 디버그 모드에서 IAP 시뮬레이션 활성화 여부 + bool _debugIAPSimulated = false; + + // =========================================================================== + // 초기화 + // =========================================================================== + + /// IAP 서비스 초기화 + Future initialize() async { + if (_isInitialized) return; + + // 모바일 플랫폼에서만 초기화 + if (!Platform.isAndroid && !Platform.isIOS) { + debugPrint('[IAPService] Non-mobile platform, skipping initialization'); + return; + } + + // 저장된 구매 상태 로드 + await _loadPurchaseState(); + + // 스토어 가용성 확인 + _isAvailable = await _iap.isAvailable(); + if (!_isAvailable) { + debugPrint('[IAPService] Store not available'); + _isInitialized = true; + return; + } + + // 구매 스트림 구독 + _subscription = _iap.purchaseStream.listen( + _onPurchaseUpdate, + onError: (Object error) { + debugPrint('[IAPService] Purchase stream error: $error'); + }, + ); + + // 상품 정보 로드 + await _loadProducts(); + + _isInitialized = true; + debugPrint('[IAPService] Initialized'); + } + + /// 상품 정보 로드 + Future _loadProducts() async { + final response = await _iap.queryProductDetails(IAPProductIds.all); + + if (response.notFoundIDs.isNotEmpty) { + debugPrint( + '[IAPService] Products not found: ${response.notFoundIDs}', + ); + } + + for (final product in response.productDetails) { + if (product.id == IAPProductIds.removeAds) { + _removeAdsProduct = product; + debugPrint( + '[IAPService] Product loaded: ${product.id} - ${product.price}', + ); + } + } + } + + /// 저장된 구매 상태 로드 + Future _loadPurchaseState() async { + final prefs = await SharedPreferences.getInstance(); + _adRemovalPurchased = prefs.getBool(_purchaseKey) ?? false; + debugPrint('[IAPService] Loaded purchase state: $_adRemovalPurchased'); + } + + /// 구매 상태 저장 + Future _savePurchaseState(bool purchased) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_purchaseKey, purchased); + _adRemovalPurchased = purchased; + debugPrint('[IAPService] Saved purchase state: $purchased'); + } + + // =========================================================================== + // 디버그 설정 + // =========================================================================== + + /// 디버그 모드 IAP 시뮬레이션 활성화 여부 + bool get debugIAPSimulated => _debugIAPSimulated; + + /// 디버그 모드 IAP 시뮬레이션 토글 + set debugIAPSimulated(bool value) { + _debugIAPSimulated = value; + if (kDebugMode) { + _adRemovalPurchased = value; + debugPrint('[IAPService] Debug IAP simulated: $value'); + } + } + + // =========================================================================== + // 구매 상태 + // =========================================================================== + + /// 광고 제거 구매 여부 + bool get isAdRemovalPurchased { + if (kDebugMode && _debugIAPSimulated) return true; + return _adRemovalPurchased; + } + + /// 스토어 가용성 + bool get isStoreAvailable => _isAvailable; + + /// 광고 제거 상품 정보 + ProductDetails? get removeAdsProduct => _removeAdsProduct; + + /// 광고 제거 상품 가격 문자열 + String get removeAdsPrice { + return _removeAdsProduct?.price ?? '\$9.99'; + } + + // =========================================================================== + // 구매 + // =========================================================================== + + /// 광고 제거 구매 + Future purchaseRemoveAds() async { + // 디버그 모드 시뮬레이션 + if (kDebugMode && _debugIAPSimulated) { + debugPrint('[IAPService] Debug: Simulating purchase'); + await _savePurchaseState(true); + return IAPResult.debugSimulated; + } + + // 이미 구매됨 + if (_adRemovalPurchased) { + debugPrint('[IAPService] Already purchased'); + return IAPResult.alreadyPurchased; + } + + // 스토어 사용 불가 + if (!_isAvailable) { + debugPrint('[IAPService] Store not available'); + return IAPResult.storeUnavailable; + } + + // 상품을 찾을 수 없음 + if (_removeAdsProduct == null) { + debugPrint('[IAPService] Product not found'); + return IAPResult.productNotFound; + } + + // 구매 요청 + final purchaseParam = PurchaseParam( + productDetails: _removeAdsProduct!, + ); + + try { + final success = await _iap.buyNonConsumable( + purchaseParam: purchaseParam, + ); + debugPrint('[IAPService] Purchase initiated: $success'); + return success ? IAPResult.success : IAPResult.failed; + } catch (e) { + debugPrint('[IAPService] Purchase error: $e'); + return IAPResult.failed; + } + } + + // =========================================================================== + // 구매 복원 + // =========================================================================== + + /// 구매 복원 + Future restorePurchases() async { + // 디버그 모드 시뮬레이션 + if (kDebugMode && _debugIAPSimulated) { + debugPrint('[IAPService] Debug: Simulating restore'); + await _savePurchaseState(true); + return IAPResult.debugSimulated; + } + + // 스토어 사용 불가 + if (!_isAvailable) { + debugPrint('[IAPService] Store not available'); + return IAPResult.storeUnavailable; + } + + try { + await _iap.restorePurchases(); + debugPrint('[IAPService] Restore initiated'); + return IAPResult.success; + } catch (e) { + debugPrint('[IAPService] Restore error: $e'); + return IAPResult.failed; + } + } + + // =========================================================================== + // 구매 처리 + // =========================================================================== + + /// 구매 업데이트 처리 + void _onPurchaseUpdate(List purchases) { + for (final purchase in purchases) { + debugPrint( + '[IAPService] Purchase update: ${purchase.productID} - ' + '${purchase.status}', + ); + + switch (purchase.status) { + case PurchaseStatus.pending: + // 구매 대기 중 + debugPrint('[IAPService] Purchase pending'); + + case PurchaseStatus.purchased: + case PurchaseStatus.restored: + // 구매 완료 또는 복원 + _handleSuccessfulPurchase(purchase); + + case PurchaseStatus.error: + // 구매 실패 + debugPrint('[IAPService] Purchase error: ${purchase.error}'); + _completePurchase(purchase); + + case PurchaseStatus.canceled: + // 구매 취소 + debugPrint('[IAPService] Purchase canceled'); + _completePurchase(purchase); + } + } + } + + /// 구매 성공 처리 + Future _handleSuccessfulPurchase(PurchaseDetails purchase) async { + if (purchase.productID == IAPProductIds.removeAds) { + // 구매 검증 (서버 검증 생략, 로컬 저장) + await _savePurchaseState(true); + debugPrint('[IAPService] Ad removal purchased successfully'); + } + + // 구매 완료 처리 + await _completePurchase(purchase); + } + + /// 구매 완료 처리 (필수) + Future _completePurchase(PurchaseDetails purchase) async { + if (purchase.pendingCompletePurchase) { + await _iap.completePurchase(purchase); + debugPrint('[IAPService] Purchase completed: ${purchase.productID}'); + } + } + + // =========================================================================== + // 정리 + // =========================================================================== + + /// 리소스 해제 + void dispose() { + _subscription?.cancel(); + _subscription = null; + debugPrint('[IAPService] Disposed'); + } +}