import 'dart:async'; import 'package:in_app_purchase/in_app_purchase.dart'; import 'package:shared_preferences/shared_preferences.dart'; class PurchaseResult { final bool success; final String? purchaseToken; final String? error; const PurchaseResult({this.success = false, this.purchaseToken, this.error}); factory PurchaseResult.ok(String token) => PurchaseResult(success: true, purchaseToken: token); factory PurchaseResult.failed([String? error]) => PurchaseResult(success: false, error: error); factory PurchaseResult.cancelled() => const PurchaseResult(success: false); } abstract class BillingService { static const proProductId = 'casha_pro_lifetime'; Future purchasePro(); Future restorePurchases(); Future queryPastPurchase(); Future dispose(); } class PlayBillingService implements BillingService { final InAppPurchase _inAppPurchase = InAppPurchase.instance; @override Future purchasePro() async { final available = await _inAppPurchase.isAvailable(); if (!available) { return PurchaseResult.failed('Billing not available'); } final response = await _inAppPurchase.queryProductDetails( {BillingService.proProductId}, ); if (response.error != null) { return PurchaseResult.failed(response.error!.message); } if (response.productDetails.isEmpty) { return PurchaseResult.failed('Product not found'); } final product = response.productDetails.first; return _waitForPurchase( timeout: const Duration(minutes: 2), timeoutError: 'Purchase timed out', start: () async { final started = await _inAppPurchase.buyNonConsumable( purchaseParam: PurchaseParam(productDetails: product), ); if (!started) { throw StateError('Could not start purchase'); } }, ); } @override Future restorePurchases() async { final available = await _inAppPurchase.isAvailable(); if (!available) { return PurchaseResult.failed('Billing not available'); } return _waitForPurchase( timeout: const Duration(seconds: 30), timeoutError: 'No purchases found', start: _inAppPurchase.restorePurchases, ); } @override Future queryPastPurchase() => restorePurchases(); Future _waitForPurchase({ required Future Function() start, required Duration timeout, required String timeoutError, }) async { final completer = Completer(); var handlingPurchase = false; late final StreamSubscription> subscription; late final Timer timeoutTimer; void finish(PurchaseResult result) { if (!completer.isCompleted) { completer.complete(result); } } Future handlePurchase(PurchaseDetails purchase) async { if (purchase.productID != BillingService.proProductId) return; switch (purchase.status) { case PurchaseStatus.pending: return; case PurchaseStatus.purchased: case PurchaseStatus.restored: if (handlingPurchase) return; handlingPurchase = true; try { final token = purchase.verificationData.serverVerificationData; if (token.isEmpty) { finish(PurchaseResult.failed('Purchase verification data is empty')); return; } if (purchase.pendingCompletePurchase) { await _inAppPurchase.completePurchase(purchase); } finish(PurchaseResult.ok(token)); } catch (error) { finish(PurchaseResult.failed(error.toString())); } finally { handlingPurchase = false; } case PurchaseStatus.error: finish(PurchaseResult.failed(purchase.error?.message)); case PurchaseStatus.canceled: finish(PurchaseResult.cancelled()); } } subscription = _inAppPurchase.purchaseStream.listen( (purchases) { for (final purchase in purchases) { unawaited(handlePurchase(purchase)); } }, onError: (Object error) { finish(PurchaseResult.failed(error.toString())); }, ); timeoutTimer = Timer( timeout, () => finish(PurchaseResult.failed(timeoutError)), ); try { await start(); } catch (error) { finish(PurchaseResult.failed(error.toString())); } final result = await completer.future; timeoutTimer.cancel(); await subscription.cancel(); return result; } @override Future dispose() async {} } class DebugBillingService implements BillingService { static const _key = 'debug_purchase_token'; final SharedPreferences _prefs; DebugBillingService(this._prefs); @override Future purchasePro() async { await Future.delayed(const Duration(seconds: 1)); final token = 'debug_token_${DateTime.now().millisecondsSinceEpoch}'; await _prefs.setString(_key, token); return PurchaseResult.ok(token); } @override Future restorePurchases() async { await Future.delayed(const Duration(seconds: 1)); final token = _prefs.getString(_key); if (token != null) { return PurchaseResult.ok(token); } return const PurchaseResult(); } @override Future queryPastPurchase() async { final token = _prefs.getString(_key); if (token != null) { return PurchaseResult.ok(token); } return const PurchaseResult(); } @override Future dispose() async {} }