I am using the in_app_purchase package in Flutter. I have the following setup:
Purchase initiation:
await InAppPurchase.instance.buyNonConsumable(purchaseParam: purchaseParam,);
Stream listener:
InAppPurchase.instance.purchaseStream.listen(
_onPurchaseUpdate,
onDone: _onStreamDone,
onError: _onError,
);
Purchase update handler:
Future<void> _onPurchaseUpdate(
List<PurchaseDetails> purchaseDetailsList,
) async {
for (final purchaseDetails in purchaseDetailsList) {
await _handlePurchase(purchaseDetails);
}
}
Individual purchase handler:
/// Handles a single purchase update.
Future<void> _handlePurchase(PurchaseDetails purchaseDetails) async {
switch (purchaseDetails.status) {
case PurchaseStatus.purchased:
// do some work
case PurchaseStatus.restored:
// do some work
case PurchaseStatus.error:
// do some work
case PurchaseStatus.canceled:
// do some work
case PurchaseStatus.pending:
// do some work
}
}
The problem:
- I open the app on iOS
- I launch the purchase flow
- The iOS payment sheet appears
- User cancels the payment sheet
- The purchase stream does not emit anything
- The loader that I show in my app when I initiated the purchase flow stays stuck forever because I don't know that the purchase was canceled
I researched a lot and I don't seem to be able to find a way to fix it.
How to know if purchase was cancelled?
1 Answer 1
TL;DR
As pointed out in a comment, the issue is that the plugin code is doing nothing in case user cancelled the flow.
Below is a workaround that solves MY PROBLEM (loader stuck forever).
High level explanation
After calling buyNonConsumable(), no app code executes - we're just waiting for the stream to emit so we can react and remove the loader.
So we can update the UI state (hide loader) BEFORE the native payment sheet appears, then show a loader again ONLY when the stream actually emits. If the stream never emits (user cancelled), the loader is already hidden, so no stuck loading state.
The Flow
1. User taps "Buy" button
→ _isLoading = true (loader shows)
2. buyProduct() executes code before calling buyNonConsumable()
→ When it finishes async work (e.g., fetching product details): Pre-initiate callback fires
→ _isLoading = false (loader hides)
→ very small time passes without a loader on the screen, and then the payment sheet appears
3. buyNonConsumable() called
→ Native payment/auth sheet appears
IF USER CANCELS:
✓ Stream never emits
✓ _isLoading stays false as we set it in the pre-initiate callback (no loader blocking UI)
IF USER COMPLETES:
✓ Stream emits purchase
✓ _isLoading = true (we set it when stream emits to show loader again)
✓ Backend verification happens
✓ _isLoading = false (we hide loader when done)
Implementation
Step 1: Wrap the IAP service with a pre-initiate callback
class IapService {
final InAppPurchase _iap = InAppPurchase.instance;
final List<VoidCallback> _preInitiateListeners = [];
// code to register/unregister listeners...
Future<void> buyProduct(String productId) async {
// Get product details, some ASYNC work (loader shown while doing this work)
final ProductDetailsResponse response = await _iap.queryProductDetails({productId});
final productDetails = response.productDetails.first;
// Notify listeners BEFORE calling buyNonConsumable, so they can hide the loader
for (final listener in _preInitiateListeners) {
listener();
}
// Now call the purchase method - native sheet will appear, loader is already hidden at this point
final purchaseParam = PurchaseParam(productDetails: productDetails);
await _iap.buyNonConsumable(purchaseParam: purchaseParam);
}
}
Step 2: Use the callback to manage UI state
class CheckoutScreen extends StatefulWidget {
@override
State<CheckoutScreen> createState() => _CheckoutScreenState();
}
class _CheckoutScreenState extends State<CheckoutScreen> {
final IapService _iapService = IapService();
StreamSubscription<List<PurchaseDetails>>? _subscription; // to capture the subscription so we can unregister it later
bool _isLoading = false;
@override
void initState() {
super.initState();
// Register the pre-initiate callback
_iapService.registerOnPreInitiatePurchaseFlow(() {
// Hide loader BEFORE native sheet appears
setState(() => _isLoading = false);
});
// Listen to purchase stream
_subscription = InAppPurchase.instance.purchaseStream.listen((purchases) {
// Show loader again when purchase stream emits, so we can in this time verify the purchase with our backend
setState(() => _isLoading = true);
// Process purchase, verify with backend, etc.
await verifyPurchaseWithBackend(purchases.first);
// some code to handle BE response ...
// Hide loader when done
setState(() => _isLoading = false);
});
}
void _onBuyButtonPressed() {
// Show initial loader
setState(() => _isLoading = true);
// Trigger purchase
_iapService.buyProduct('your_product_id');
}
// build method...
// Unregister the pre-initiate callback and dispose subscription in dispose...
}
v0.4.6but it is not present inv0.4.5. You can downgrade, if you can, to fix this temporarily. There's also a new GitHub issue opened today on this: github.com/flutter/flutter/issues/178292.