import 'dart:async'; import 'dart:io'; import 'package:in_app_purchase/in_app_purchase.dart'; import 'package:in_app_purchase_android/in_app_purchase_android.dart'; /// Wraps the app's one purchasable product: a *consumable* one-time /// purchase that grants a year of no ads (see /// [AppState.adsCurrentlyDisabled]) — consumable specifically so it can be /// bought again once that year lapses, unlike a plain non-consumable /// (which Play Store would only ever let you own once, permanently) or an /// auto-renewing subscription (which would charge the user again every /// year without them actively choosing to). /// /// [adFreeYearProductId] is a placeholder — nothing will actually load or /// be purchasable until a real in-app product with this same ID exists in /// your Google Play Console (Monetize > Products > In-app products), /// configured as a *managed product*, with whatever price you choose /// there (Play Billing doesn't take a price from the app itself). Rename /// the constant below to match whatever product ID you actually create, /// if you'd rather not use this one. class PurchaseService { static const adFreeYearProductId = 'ad_free_year'; /// Called once for every successful (or restored) purchase of /// [adFreeYearProductId] — [AppState] is what actually records the /// resulting entitlement; this service only reports that a purchase /// happened. final void Function() onPurchaseGranted; /// Called with a user-facing message when a purchase attempt fails — /// [AppState] surfaces this via [AppState.purchaseError] for the /// Settings screen to display. final void Function(String message)? onPurchaseError; PurchaseService({required this.onPurchaseGranted, this.onPurchaseError}); StreamSubscription>? _subscription; ProductDetails? _product; /// The store's own formatted, localized price string (e.g. `"$4.99"`) /// once [initialize] has loaded the product — null before that, or if /// the product ID above doesn't match anything configured in the store /// yet. String? get priceLabel => _product?.price; Future initialize() async { final available = await InAppPurchase.instance.isAvailable(); if (!available) return; _subscription = InAppPurchase.instance.purchaseStream.listen( _handlePurchaseUpdates, onDone: () => _subscription?.cancel(), onError: (_) {}, ); final response = await InAppPurchase.instance.queryProductDetails({adFreeYearProductId}); if (response.productDetails.isNotEmpty) { _product = response.productDetails.first; } } /// Kicks off the platform purchase UI. Does nothing (and reports an /// error) if the product hasn't loaded — either the store isn't /// available, or [adFreeYearProductId] doesn't match a real product yet. Future buyAdFreeYear() async { final product = _product; if (product == null) { onPurchaseError?.call("This purchase isn't available right now."); return; } await InAppPurchase.instance.buyConsumable( purchaseParam: PurchaseParam(productDetails: product), ); } Future _handlePurchaseUpdates(List purchases) async { for (final purchase in purchases) { switch (purchase.status) { case PurchaseStatus.purchased: case PurchaseStatus.restored: if (purchase.productID == adFreeYearProductId) { onPurchaseGranted(); } // Android specifically: a *consumable* purchase has to be // explicitly "consumed" or Play Billing considers it still owned // and refuses to sell it again next year. iOS has no equivalent // step — StoreKit consumables are inherently one-shot already. if (Platform.isAndroid) { final androidAddition = InAppPurchase.instance.getPlatformAddition(); await androidAddition.consumePurchase(purchase); } if (purchase.pendingCompletePurchase) { await InAppPurchase.instance.completePurchase(purchase); } case PurchaseStatus.error: onPurchaseError?.call(purchase.error?.message ?? 'The purchase failed.'); if (purchase.pendingCompletePurchase) { await InAppPurchase.instance.completePurchase(purchase); } case PurchaseStatus.pending: case PurchaseStatus.canceled: break; } } } void dispose() { _subscription?.cancel(); } }