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. /// Matches the IAPs registered in App Store Connect (Apple ID 6779190925) /// and Google Play Console (one-time product `com.tekmidian.pailot.pro`). const String kFullAccessProductId = 'com.tekmidian.pailot.pro'; /// Maximum sessions allowed on the free tier. const int kFreeTierMaxSessions = 2; /// Maximum message age for free-tier users. const Duration kFreeTierMessageTtl = Duration(minutes: 30); /// 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 { // Hydrate from local cache first so UI doesn't flash "Free" before the // store reply lands. The cache is authoritative for offline / cold-start // rendering; restorePurchases() reconciles it with the platform stores. final prefs = await SharedPreferences.getInstance(); _isPro = prefs.getBool(_kProCacheKey) ?? false; // Owner override: personal / sideloaded builds unlock Pro without a purchase. // Build with: flutter run --dart-define=PAILOT_PRO=true // Defaults to false via bool.fromEnvironment, so App Store builds (which do // not pass this define) are unaffected — real users still see the paywall. const proOverride = bool.fromEnvironment('PAILOT_PRO'); if (proOverride) { _isPro = true; await prefs.setBool(_kProCacheKey, true); notifyListeners(); return; } // Screenshot / demo override: force free-tier rendering for store-listing // captures. Build with: flutter run --dart-define=PAILOT_FORCE_FREE=true const forceFree = bool.fromEnvironment('PAILOT_FORCE_FREE'); if (forceFree) { _isPro = false; await prefs.setBool(_kProCacheKey, false); notifyListeners(); return; } notifyListeners(); // If the platform IAP layer isn't available (sideloaded dev build, no // network, simulator without StoreKit config), keep the cached value as-is // and skip the store dance. In debug, default to Pro so dev testing isn't // blocked by the paywall — but never override in release builds. final available = await InAppPurchase.instance.isAvailable(); if (!available) { if (kDebugMode && !_isPro) { debugPrint('[IAP] Store unavailable in debug build — granting Pro for dev testing'); _isPro = true; await prefs.setBool(_kProCacheKey, true); notifyListeners(); } else { debugPrint('[IAP] Store unavailable; using cached isPro=$_isPro'); } 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(); } }