A whole lot of stuff
This commit is contained in:
parent
faf319322a
commit
ad850d2be6
50 changed files with 3013 additions and 213 deletions
|
|
@ -1,35 +1,52 @@
|
|||
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).
|
||||
/// Wraps the app's one purchasable product: a one-year, non-auto-renewing
|
||||
/// *prepaid subscription* that grants a year of no ads (see
|
||||
/// [AppState.adsCurrentlyDisabled]).
|
||||
///
|
||||
/// Prepaid subscription, specifically — not a consumable, a plain
|
||||
/// non-consumable, or an auto-renewing subscription:
|
||||
/// - A *consumable* has to be acknowledged (= consumed, for this product
|
||||
/// type) within 3 days of purchase or Play Billing auto-refunds it —
|
||||
/// there's no way to leave it unconsumed so it stays restorable for the
|
||||
/// whole year. And once consumed, Play Billing forgets it existed, so a
|
||||
/// user who loses local data (no cloud backup connected) has nothing
|
||||
/// left to restore from. This is what this product used to be, before
|
||||
/// that gap was found.
|
||||
/// - A plain *non-consumable* would only ever let a Google account buy it
|
||||
/// once, permanently — doesn't fit "buy another year once this one
|
||||
/// lapses".
|
||||
/// - An *auto-renewing subscription* would charge the user again every
|
||||
/// year without them actively choosing to.
|
||||
/// - A *prepaid* subscription base plan fits all three constraints: it's
|
||||
/// acknowledged immediately (same 3-day rule, satisfied same as any
|
||||
/// purchase), stays active — and restorable via [restorePurchase] — for
|
||||
/// its whole prepaid year without needing to be consumed, then simply
|
||||
/// expires with no charge and can be bought again.
|
||||
///
|
||||
/// [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.
|
||||
/// be purchasable until a real subscription with this same ID exists in
|
||||
/// your Google Play Console (Monetize > Products > Subscriptions), with a
|
||||
/// *prepaid* base plan named [_basePlanId] and whatever price/duration you
|
||||
/// choose there (Play Billing doesn't take a price from the app itself).
|
||||
/// Rename either constant to match whatever you actually create, if you'd
|
||||
/// rather not use these.
|
||||
class PurchaseService {
|
||||
static const adFreeYearProductId = 'ad_free_year';
|
||||
static const _basePlanId = 'ad-free-year-prepaid';
|
||||
|
||||
/// 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;
|
||||
/// [adFreeYearProductId], with the time Play Billing recorded the
|
||||
/// purchase actually happening — [AppState] anchors the year of ad-free
|
||||
/// time to that, not to "now", so restoring an existing purchase doesn't
|
||||
/// hand out a free extra year on top of time already elapsed.
|
||||
final void Function(DateTime purchaseTime) onPurchaseGranted;
|
||||
|
||||
/// Called with a user-facing message when a purchase attempt fails —
|
||||
/// [AppState] surfaces this via [AppState.purchaseError] for the
|
||||
/// Called with a user-facing message when a purchase or restore attempt
|
||||
/// fails — [AppState] surfaces this via [AppState.purchaseError] for the
|
||||
/// Settings screen to display.
|
||||
final void Function(String message)? onPurchaseError;
|
||||
|
||||
|
|
@ -40,8 +57,8 @@ class PurchaseService {
|
|||
|
||||
/// 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.
|
||||
/// neither the product ID nor base plan ID above matches anything
|
||||
/// configured in the store yet.
|
||||
String? get priceLabel => _product?.price;
|
||||
|
||||
Future<void> initialize() async {
|
||||
|
|
@ -55,23 +72,55 @@ class PurchaseService {
|
|||
);
|
||||
|
||||
final response = await InAppPurchase.instance.queryProductDetails({adFreeYearProductId});
|
||||
if (response.productDetails.isNotEmpty) {
|
||||
_product = response.productDetails.first;
|
||||
_product = _selectOffer(response.productDetails);
|
||||
}
|
||||
|
||||
/// For a subscription product, [InAppPurchase.queryProductDetails]
|
||||
/// returns one [ProductDetails] per offer/base-plan combination it has,
|
||||
/// not one per product ID — picks the prepaid base plan set up for this
|
||||
/// product specifically, falling back to whichever offer loaded first so
|
||||
/// a Play Console setup with only the one base plan (the expected,
|
||||
/// common case here) still works without its name needing to match
|
||||
/// exactly.
|
||||
ProductDetails? _selectOffer(List<ProductDetails> offers) {
|
||||
for (final offer in offers) {
|
||||
if (offer is! GooglePlayProductDetails) continue;
|
||||
final index = offer.subscriptionIndex;
|
||||
final basePlanId =
|
||||
index == null ? null : offer.productDetails.subscriptionOfferDetails?[index].basePlanId;
|
||||
if (basePlanId == _basePlanId) return offer;
|
||||
}
|
||||
return offers.isEmpty ? null : offers.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.
|
||||
/// available, or neither [adFreeYearProductId] nor [_basePlanId] matches
|
||||
/// 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),
|
||||
);
|
||||
final purchaseParam = product is GooglePlayProductDetails
|
||||
? GooglePlayPurchaseParam(productDetails: product, offerToken: product.offerToken)
|
||||
: PurchaseParam(productDetails: product);
|
||||
await InAppPurchase.instance.buyNonConsumable(purchaseParam: purchaseParam);
|
||||
}
|
||||
|
||||
/// Re-derives the local entitlement from Play Billing's own record of an
|
||||
/// active (not yet expired) prepaid purchase — for a user who reinstalled
|
||||
/// or switched devices without cloud backup connected, where nothing
|
||||
/// local survived to sync the entitlement back down. A silent no-op if
|
||||
/// there's nothing currently active to find, which is the normal outcome
|
||||
/// for anyone who's never purchased, so that's not treated as an error.
|
||||
Future<void> restorePurchase() async {
|
||||
try {
|
||||
await InAppPurchase.instance.restorePurchases();
|
||||
} on InAppPurchaseException catch (e) {
|
||||
onPurchaseError?.call(e.message ?? "Couldn't restore your purchase. Try again later.");
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handlePurchaseUpdates(List<PurchaseDetails> purchases) async {
|
||||
|
|
@ -80,16 +129,7 @@ class PurchaseService {
|
|||
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);
|
||||
onPurchaseGranted(_purchaseTime(purchase));
|
||||
}
|
||||
if (purchase.pendingCompletePurchase) {
|
||||
await InAppPurchase.instance.completePurchase(purchase);
|
||||
|
|
@ -106,6 +146,18 @@ class PurchaseService {
|
|||
}
|
||||
}
|
||||
|
||||
/// Play reports this as epoch milliseconds in a string (see
|
||||
/// `GooglePlayPurchaseDetails.transactionDate`) — falls back to the
|
||||
/// current time in the shouldn't-happen case that it's missing or
|
||||
/// malformed, rather than leaving the entitlement ungranted over a
|
||||
/// parsing hiccup.
|
||||
DateTime _purchaseTime(PurchaseDetails purchase) {
|
||||
final millis = int.tryParse(purchase.transactionDate ?? '');
|
||||
return millis == null
|
||||
? DateTime.now().toUtc()
|
||||
: DateTime.fromMillisecondsSinceEpoch(millis, isUtc: true);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_subscription?.cancel();
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue