112 lines
4.5 KiB
Dart
112 lines
4.5 KiB
Dart
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<List<PurchaseDetails>>? _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<void> 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<void> 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<void> _handlePurchaseUpdates(List<PurchaseDetails> 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<InAppPurchaseAndroidPlatformAddition>();
|
|
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();
|
|
}
|
|
}
|