feat(iap): 인앱 결제 서비스 추가
- 광고 제거 상품 구매 처리 - 구매 복원 기능 - 결제 상태 스트림 지원
This commit is contained in:
341
lib/src/core/engine/iap_service.dart
Normal file
341
lib/src/core/engine/iap_service.dart
Normal file
@@ -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<String> 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<List<PurchaseDetails>>? _subscription;
|
||||||
|
|
||||||
|
/// 광고 제거 구매 여부 (캐시)
|
||||||
|
bool _adRemovalPurchased = false;
|
||||||
|
|
||||||
|
/// 디버그 모드에서 IAP 시뮬레이션 활성화 여부
|
||||||
|
bool _debugIAPSimulated = false;
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 초기화
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/// IAP 서비스 초기화
|
||||||
|
Future<void> 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<void> _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<void> _loadPurchaseState() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
_adRemovalPurchased = prefs.getBool(_purchaseKey) ?? false;
|
||||||
|
debugPrint('[IAPService] Loaded purchase state: $_adRemovalPurchased');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 구매 상태 저장
|
||||||
|
Future<void> _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<IAPResult> 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<IAPResult> 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<PurchaseDetails> 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<void> _handleSuccessfulPurchase(PurchaseDetails purchase) async {
|
||||||
|
if (purchase.productID == IAPProductIds.removeAds) {
|
||||||
|
// 구매 검증 (서버 검증 생략, 로컬 저장)
|
||||||
|
await _savePurchaseState(true);
|
||||||
|
debugPrint('[IAPService] Ad removal purchased successfully');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 구매 완료 처리
|
||||||
|
await _completePurchase(purchase);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 구매 완료 처리 (필수)
|
||||||
|
Future<void> _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');
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user