Move purchasing logic to PurchaseManager

Hopefully with this there will never be any more problems of purchases
not being completed.
This commit is contained in:
Vishesh Handa
2020-11-27 17:19:42 +01:00
parent 4eff5844b1
commit 0b5701b739
3 changed files with 78 additions and 132 deletions

View File

@ -7,21 +7,18 @@ import 'package:gitjournal/app_settings.dart';
import 'package:gitjournal/error_reporting.dart'; import 'package:gitjournal/error_reporting.dart';
import 'package:gitjournal/iap.dart'; import 'package:gitjournal/iap.dart';
import 'package:gitjournal/utils/logger.dart'; import 'package:gitjournal/utils/logger.dart';
import 'package:gitjournal/widgets/purchase_slider.dart';
enum PurchaseError {
StoreCannotBeReached,
}
// ignore_for_file: cancel_subscriptions // ignore_for_file: cancel_subscriptions
typedef PurchaseCallback = void Function(PurchaseError, SubscriptionStatus); typedef PurchaseCallback = void Function(String, SubscriptionStatus);
class PurchaseManager { class PurchaseManager {
InAppPurchaseConnection con; InAppPurchaseConnection con;
StreamSubscription<List<PurchaseDetails>> _subscription; StreamSubscription<List<PurchaseDetails>> _subscription;
List<PurchaseCallback> _callbacks = []; List<PurchaseCallback> _callbacks = [];
static PurchaseError error; static String error;
static PurchaseManager _instance; static PurchaseManager _instance;
static Future<PurchaseManager> init() async { static Future<PurchaseManager> init() async {
@ -36,7 +33,7 @@ class PurchaseManager {
final bool available = await _instance.con.isAvailable(); final bool available = await _instance.con.isAvailable();
if (!available) { if (!available) {
error = PurchaseError.StoreCannotBeReached; error = "Store cannot be reached";
_instance = null; _instance = null;
return null; return null;
} }
@ -100,10 +97,17 @@ class PurchaseManager {
void _handleIAPError(IAPError err) { void _handleIAPError(IAPError err) {
var msg = "${err.code} - ${err.message} - ${err.details}"; var msg = "${err.code} - ${err.message} - ${err.details}";
Log.e(msg); Log.e(msg);
_handleError(msg);
} }
void _handleError(String err) { void _handleError(String err) {
Log.e(err); Log.e(err);
Log.i("Calling Purchase Error Callbacks: ${_callbacks.length}");
for (var callback in _callbacks) {
callback(err, null);
}
} }
void _deliverProduct(SubscriptionStatus status) { void _deliverProduct(SubscriptionStatus status) {
@ -112,14 +116,22 @@ class PurchaseManager {
appSettings.proExpirationDate = status.expiryDate.toIso8601String(); appSettings.proExpirationDate = status.expiryDate.toIso8601String();
appSettings.save(); appSettings.save();
Log.i("Calling Purchase Completed Callbacks: ${_callbacks.length}");
for (var callback in _callbacks) { for (var callback in _callbacks) {
callback(null, status); callback("", status);
} }
} }
/// Returns the ProductDetails sorted by price
Future<ProductDetailsResponse> queryProductDetails(Set<String> skus) async { Future<ProductDetailsResponse> queryProductDetails(Set<String> skus) async {
// Cache this response? // Cache this response?
final response = await _instance.con.queryProductDetails(skus); var response = await _instance.con.queryProductDetails(skus);
response.productDetails.sort((a, b) {
var pa = PaymentInfo.fromProductDetail(a);
var pb = PaymentInfo.fromProductDetail(b);
return pa.value.compareTo(pb.value);
});
return response; return response;
} }

View File

@ -8,7 +8,6 @@ import 'package:gitjournal/analytics.dart';
import 'package:gitjournal/purchase_manager.dart'; import 'package:gitjournal/purchase_manager.dart';
import 'package:gitjournal/screens/feature_timeline_screen.dart'; import 'package:gitjournal/screens/feature_timeline_screen.dart';
import 'package:gitjournal/utils/logger.dart'; import 'package:gitjournal/utils/logger.dart';
import 'package:gitjournal/widgets/purchase_slider.dart';
import 'package:gitjournal/widgets/purchase_widget.dart'; import 'package:gitjournal/widgets/purchase_widget.dart';
import 'package:gitjournal/widgets/scroll_view_without_animation.dart'; import 'package:gitjournal/widgets/scroll_view_without_animation.dart';
@ -54,18 +53,10 @@ class _PurchaseScreenState extends State<PurchaseScreen> {
} }
if (!mounted) return; if (!mounted) return;
if (response.productDetails.isEmpty) return;
var products = response.productDetails;
products.sort((a, b) {
var pa = PaymentInfo.fromProductDetail(a);
var pb = PaymentInfo.fromProductDetail(b);
return pa.value.compareTo(pb.value);
});
if (products.isEmpty) return;
setState(() { setState(() {
minYearlyPurchase = products.first.price; minYearlyPurchase = response.productDetails.first.price;
}); });
} }

View File

@ -3,13 +3,14 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:function_types/function_types.dart';
import 'package:in_app_purchase/in_app_purchase.dart'; import 'package:in_app_purchase/in_app_purchase.dart';
import 'package:provider/provider.dart';
import 'package:gitjournal/analytics.dart'; import 'package:gitjournal/analytics.dart';
import 'package:gitjournal/app_settings.dart'; import 'package:gitjournal/app_settings.dart';
import 'package:gitjournal/error_reporting.dart'; import 'package:gitjournal/error_reporting.dart';
import 'package:gitjournal/iap.dart'; import 'package:gitjournal/iap.dart';
import 'package:gitjournal/purchase_manager.dart';
import 'package:gitjournal/utils/logger.dart'; import 'package:gitjournal/utils/logger.dart';
import 'package:gitjournal/widgets/purchase_slider.dart'; import 'package:gitjournal/widgets/purchase_slider.dart';
@ -17,8 +18,16 @@ class PurchaseButton extends StatelessWidget {
final ProductDetails product; final ProductDetails product;
final String timePeriod; final String timePeriod;
final bool subscription; final bool subscription;
final Func1<bool, void> purchaseStarted;
final PurchaseCallback purchaseCompleted;
PurchaseButton(this.product, this.timePeriod, {@required this.subscription}); PurchaseButton(
this.product,
this.timePeriod, {
@required this.subscription,
@required this.purchaseStarted,
@required this.purchaseCompleted,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -43,14 +52,22 @@ class PurchaseButton extends StatelessWidget {
} }
Future<void> _initPurchase(BuildContext context) async { Future<void> _initPurchase(BuildContext context) async {
var purchaseParam = PurchaseParam(productDetails: product); var pm = await PurchaseManager.init();
var sentSuccess = await InAppPurchaseConnection.instance if (pm == null) {
.buyNonConsumable(purchaseParam: purchaseParam); purchaseCompleted(PurchaseManager.error, null);
return;
}
var sentSuccess = await pm.buyNonConsumable(product, purchaseCompleted);
purchaseStarted(sentSuccess);
/*
if (!sentSuccess) { if (!sentSuccess) {
var dialog = PurchaseFailedDialog(tr("widgets.PurchaseButton.failSend")); var dialog = PurchaseFailedDialog(tr("widgets.PurchaseButton.failSend"));
await showDialog(context: context, builder: (context) => dialog); await showDialog(context: context, builder: (context) => dialog);
return;
} }
*/
} }
void _reportExceptions(BuildContext context) async { void _reportExceptions(BuildContext context) async {
@ -89,10 +106,9 @@ class PurchaseWidget extends StatefulWidget {
class _PurchaseWidgetState extends State<PurchaseWidget> { class _PurchaseWidgetState extends State<PurchaseWidget> {
List<ProductDetails> _products; List<ProductDetails> _products;
ProductDetails _selectedProduct; ProductDetails _selectedProduct;
StreamSubscription<List<PurchaseDetails>> _subscription;
String error = ""; String error = "";
bool pendingPurchase = false; bool _pendingPurchase = false;
@override @override
void initState() { void initState() {
@ -101,33 +117,21 @@ class _PurchaseWidgetState extends State<PurchaseWidget> {
} }
Future<void> initPlatformState() async { Future<void> initPlatformState() async {
InAppPurchaseConnection.enablePendingPurchases(); var pm = await PurchaseManager.init();
final iapCon = InAppPurchaseConnection.instance; if (pm == null) {
final bool available = await iapCon.isAvailable();
if (!available) {
setState(() { setState(() {
error = "Store cannot be reached"; error = PurchaseManager.error;
}); });
return;
} }
final response = await iapCon.queryProductDetails(widget.skus); final response = await pm.queryProductDetails(widget.skus);
if (response.error != null) { if (response.error != null) {
Log.e("IAP queryProductDetails: ${response.error}"); Log.e("IAP queryProductDetails: ${response.error}");
} }
// If the widget was removed from the tree while the asynchronous platform
// message was in flight, we want to discard the reply rather than calling
// setState to update our non-existent appearance.
if (!mounted) return; if (!mounted) return;
var products = response.productDetails; var products = response.productDetails;
products.sort((a, b) {
var pa = PaymentInfo.fromProductDetail(a);
var pb = PaymentInfo.fromProductDetail(b);
return pa.value.compareTo(pb.value);
});
Log.i("Products: ${products.length}"); Log.i("Products: ${products.length}");
for (var p in products) { for (var p in products) {
Log.i("Product ${p.id} -> ${p.price}"); Log.i("Product ${p.id} -> ${p.price}");
@ -148,94 +152,6 @@ class _PurchaseWidgetState extends State<PurchaseWidget> {
// FIXME: Add a fake product for development // FIXME: Add a fake product for development
} }
}); });
// Start listening for changes
final purchaseUpdates = iapCon.purchaseUpdatedStream;
_subscription = purchaseUpdates.listen(_listenToPurchaseUpdated);
}
void _listenToPurchaseUpdated(
List<PurchaseDetails> purchaseDetailsList) async {
for (var pd in purchaseDetailsList) {
await _handlePurchaseUpdate(pd);
}
}
Future<void> _handlePurchaseUpdate(PurchaseDetails purchaseDetails) async {
Log.i(
"PurchaseDetailsUpdated: {productID: ${purchaseDetails.productID}, purchaseID: ${purchaseDetails.purchaseID}, status: ${purchaseDetails.status}");
if (purchaseDetails.status == PurchaseStatus.pending) {
//showPendingUI();
Log.i("Pending - ${purchaseDetails.productID}");
if (mounted) {
setState(() {
pendingPurchase = true;
});
}
return;
}
setState(() {
pendingPurchase = false;
});
if (purchaseDetails.status == PurchaseStatus.error) {
_handleIAPError(purchaseDetails.error);
return;
} else if (purchaseDetails.status == PurchaseStatus.purchased) {
Log.i("Verifying purchase sub");
try {
var subStatus = await verifyPurchase(purchaseDetails);
if (subStatus.isPro) {
_deliverProduct(subStatus);
} else {
_handleError(tr('widgets.PurchaseWidget.failed'));
return;
}
} catch (err) {
_handleError(err.toString());
}
}
if (purchaseDetails.pendingCompletePurchase) {
Log.i("Pending Complete Purchase - ${purchaseDetails.productID}");
try {
await InAppPurchaseConnection.instance
.completePurchase(purchaseDetails);
} catch (e, stackTrace) {
logException(e, stackTrace);
}
}
}
void _handleIAPError(IAPError err) {
var msg = "${err.code} - ${err.message} - ${err.details}";
_handleError(msg);
}
void _handleError(String err) {
if (err.toLowerCase().contains("usercanceled")) {
Log.e(err);
return;
}
var dialog = PurchaseFailedDialog(err);
showDialog(context: context, builder: (context) => dialog);
}
void _deliverProduct(SubscriptionStatus status) {
var appSettings = Provider.of<AppSettings>(context, listen: false);
appSettings.proMode = status.isPro;
appSettings.proExpirationDate = status.expiryDate.toIso8601String();
appSettings.save();
logEvent(Event.PurchaseScreenThankYou);
Navigator.of(context).popAndPushNamed('/purchase_thank_you');
}
@override
void dispose() {
_subscription?.cancel();
super.dispose();
} }
@override @override
@ -243,7 +159,7 @@ class _PurchaseWidgetState extends State<PurchaseWidget> {
if (error.isNotEmpty) { if (error.isNotEmpty) {
return Text("Failed to load: $error"); return Text("Failed to load: $error");
} }
if (pendingPurchase) { if (_pendingPurchase) {
return const CircularProgressIndicator(); return const CircularProgressIndicator();
} }
return _products == null return _products == null
@ -301,6 +217,12 @@ class _PurchaseWidgetState extends State<PurchaseWidget> {
_selectedProduct, _selectedProduct,
widget.timePeriod, widget.timePeriod,
subscription: widget.isSubscription, subscription: widget.isSubscription,
purchaseStarted: (bool started) {
setState(() {
_pendingPurchase = started;
});
},
purchaseCompleted: _purchaseCompleted,
), ),
], ],
mainAxisAlignment: MainAxisAlignment.spaceAround, mainAxisAlignment: MainAxisAlignment.spaceAround,
@ -326,6 +248,27 @@ class _PurchaseWidgetState extends State<PurchaseWidget> {
return null; return null;
} }
void _purchaseCompleted(String err, SubscriptionStatus subStatus) {
if (!mounted) return;
if (err.isEmpty) {
Log.i("Purchase Completed: $subStatus");
logEvent(Event.PurchaseScreenThankYou);
Navigator.of(context).popAndPushNamed('/purchase_thank_you');
return;
}
if (err.toLowerCase().contains("usercanceled")) {
setState(() {
_pendingPurchase = false;
});
Log.e(err);
return;
}
var dialog = PurchaseFailedDialog(err);
showDialog(context: context, builder: (context) => dialog);
}
} }
class _PurchaseSliderButton extends StatelessWidget { class _PurchaseSliderButton extends StatelessWidget {