import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:in_app_purchase/in_app_purchase.dart'; import 'package:shared_preferences/shared_preferences.dart'; /// Product ID for the one-time full-access purchase. const String kFullAccessProductId = 'com.tekmidian.pailot.fullaccess'; /// Maximum sessions allowed on the free tier. const int kFreeTierMaxSessions = 2; /// Maximum message age for free-tier users (15 minutes). const Duration kFreeTierMessageTtl = Duration(minutes: 15); /// Shared preference key for caching purchase status locally. const String _kProCacheKey = 'pailot_is_pro'; /// Service that manages the StoreKit 2 / in_app_purchase lifecycle. /// /// Usage: /// final svc = PurchaseService(); /// await svc.initialize(); /// svc.addListener(() { ... }); /// bool pro = svc.isPro; /// /// Call [dispose] when done. class PurchaseService extends ChangeNotifier { PurchaseService._(); static final PurchaseService instance = PurchaseService._(); bool _isPro = false; bool _isLoading = false; String? _errorMessage; StreamSubscription>? _subscription; /// Whether the user has purchased full access. bool get isPro => _isPro; /// True while a purchase or restore operation is in progress. bool get isLoading => _isLoading; /// Non-null if the last operation produced an error message. String? get errorMessage => _errorMessage; // --------------------------------------------------------------------------- // Lifecycle // --------------------------------------------------------------------------- /// Initialize the service. Call once at app startup. Future initialize() async { // Restore cached value immediately so UI doesn't flicker. // Default to true for dev/sideloaded builds (no StoreKit configured). final prefs = await SharedPreferences.getInstance(); // Force pro for now — clear any stale false value from earlier testing _isPro = true; await prefs.setBool(_kProCacheKey, true); notifyListeners(); // Check if IAP is available — may not be on dev/sideloaded builds final available = await InAppPurchase.instance.isAvailable(); if (!available) { debugPrint('[IAP] StoreKit not available — assuming pro (dev build)'); _isPro = true; notifyListeners(); return; } // Listen for ongoing purchase updates. final purchaseUpdated = InAppPurchase.instance.purchaseStream; _subscription = purchaseUpdated.listen( _handlePurchaseUpdates, onError: (Object err) { _errorMessage = err.toString(); notifyListeners(); }, ); // Verify with StoreKit on each launch (catches refunds / family sharing). await restorePurchases(silent: true); } @override void dispose() { _subscription?.cancel(); super.dispose(); } // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- /// Initiate the purchase flow for full access. Future purchaseFullAccess() async { _errorMessage = null; _isLoading = true; notifyListeners(); try { final bool available = await InAppPurchase.instance.isAvailable(); if (!available) { _errorMessage = 'Store not available. Check your internet connection.'; _isLoading = false; notifyListeners(); return; } final ProductDetailsResponse response = await InAppPurchase.instance .queryProductDetails({kFullAccessProductId}); if (response.error != null || response.productDetails.isEmpty) { _errorMessage = 'Product not found. Please try again later.'; _isLoading = false; notifyListeners(); return; } final ProductDetails product = response.productDetails.first; final PurchaseParam param = PurchaseParam(productDetails: product); await InAppPurchase.instance.buyNonConsumable(purchaseParam: param); // Result arrives via purchaseStream — _isLoading cleared there. } catch (e) { _errorMessage = 'Purchase failed: $e'; _isLoading = false; notifyListeners(); } } /// Restore previously completed purchases (also called on app launch). Future restorePurchases({bool silent = false}) async { if (!silent) { _errorMessage = null; _isLoading = true; notifyListeners(); } try { await InAppPurchase.instance.restorePurchases(); // Results arrive asynchronously via purchaseStream. // For non-silent restores _isLoading is cleared there. if (silent) { // Give the stream a moment to deliver any results. await Future.delayed(const Duration(seconds: 2)); } } catch (e) { if (!silent) { _errorMessage = 'Restore failed: $e'; _isLoading = false; notifyListeners(); } } } // --------------------------------------------------------------------------- // Internal // --------------------------------------------------------------------------- Future _handlePurchaseUpdates( List purchases) async { for (final PurchaseDetails purchase in purchases) { if (purchase.productID != kFullAccessProductId) continue; switch (purchase.status) { case PurchaseStatus.purchased: case PurchaseStatus.restored: await _deliverPurchase(purchase); break; case PurchaseStatus.error: _errorMessage = purchase.error?.message ?? 'Purchase failed.'; _isLoading = false; notifyListeners(); break; case PurchaseStatus.canceled: _isLoading = false; notifyListeners(); break; case PurchaseStatus.pending: // Show loading while pending (e.g. Ask to Buy). _isLoading = true; notifyListeners(); break; } // Complete the transaction to prevent it from being re-delivered. if (purchase.pendingCompletePurchase) { await InAppPurchase.instance.completePurchase(purchase); } } } Future _deliverPurchase(PurchaseDetails purchase) async { _isPro = true; _isLoading = false; _errorMessage = null; // Persist so the next app launch restores from cache quickly. final prefs = await SharedPreferences.getInstance(); await prefs.setBool(_kProCacheKey, true); debugPrint('[Purchase] Full access granted for ${purchase.productID}'); notifyListeners(); } }