mirror of
https://github.com/GitJournal/GitJournal.git
synced 2025-06-29 02:07:39 +08:00
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:
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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 {
|
||||||
|
Reference in New Issue
Block a user