Ad and purchases enabled (still need actual testing)

This commit is contained in:
Courtney Arnold 2026-08-16 18:27:56 -05:00
parent 2fcc6dcab7
commit faf319322a
35 changed files with 1676 additions and 34 deletions

View file

@ -0,0 +1,112 @@
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();
}
}