mirror of
https://github.com/GitJournal/GitJournal.git
synced 2025-09-12 14:09:12 +08:00

The iOS updates keep getting rejected, and I think it's because RevenueCat is taking too long to respond. Additionally, revenueCat doesn't really give us anything useful as its receipt validation isn't perfect, and I've had to roll my own. Plus from a privacy point of view, this is better as we are no longer talking to any third party service. This has so far only been tested on iOS
330 lines
8.7 KiB
Dart
330 lines
8.7 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
import 'package:in_app_purchase/in_app_purchase.dart';
|
|
|
|
import 'package:gitjournal/analytics.dart';
|
|
import 'package:gitjournal/iap.dart';
|
|
import 'package:gitjournal/settings.dart';
|
|
import 'package:gitjournal/utils/logger.dart';
|
|
import 'package:gitjournal/widgets/purchase_slider.dart';
|
|
|
|
class PurchaseButton extends StatelessWidget {
|
|
final ProductDetails product;
|
|
|
|
PurchaseButton(this.product);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
var price = product != null ? product.price : "Dev Mode";
|
|
|
|
return RaisedButton(
|
|
child: Text('Subscribe for $price / month'),
|
|
color: Theme.of(context).primaryColor,
|
|
padding: const EdgeInsets.fromLTRB(32.0, 16.0, 32.0, 16.0),
|
|
onPressed: product != null ? () => _initPurchase(context) : null,
|
|
);
|
|
}
|
|
|
|
void _initPurchase(BuildContext context) async {
|
|
var purchaseParam = PurchaseParam(productDetails: product);
|
|
var sentSuccess = await InAppPurchaseConnection.instance
|
|
.buyNonConsumable(purchaseParam: purchaseParam);
|
|
|
|
if (!sentSuccess) {
|
|
var err = "Failed to send purchase request";
|
|
var dialog = PurchaseFailedDialog(err);
|
|
await showDialog(context: context, builder: (context) => dialog);
|
|
return;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|
|
|
|
Set<String> _generateSkus() {
|
|
var list = <String>{'sku_monthly_min'};
|
|
for (var i = 0; i < 50; i++) {
|
|
list.add("sku_monthly_min$i");
|
|
}
|
|
print(list);
|
|
return list;
|
|
}
|
|
|
|
class PurchaseWidget extends StatefulWidget {
|
|
@override
|
|
_PurchaseWidgetState createState() => _PurchaseWidgetState();
|
|
}
|
|
|
|
class _PurchaseWidgetState extends State<PurchaseWidget> {
|
|
List<ProductDetails> _products;
|
|
ProductDetails _selectedProduct;
|
|
StreamSubscription<List<PurchaseDetails>> _subscription;
|
|
|
|
final defaultSku = "sku_monthly_min2";
|
|
String error = "";
|
|
bool pendingPurchase = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
initPlatformState();
|
|
}
|
|
|
|
Future<void> initPlatformState() async {
|
|
// In parallel check if the purchase has been made
|
|
InAppPurchases.confirmProPurchase().then((void _) {
|
|
if (Settings.instance.proMode) {
|
|
Navigator.of(context).pop();
|
|
}
|
|
});
|
|
|
|
final iapCon = InAppPurchaseConnection.instance;
|
|
|
|
final bool available = await iapCon.isAvailable();
|
|
if (!available) {
|
|
setState(() {
|
|
error = "Store cannot be reached";
|
|
});
|
|
return;
|
|
}
|
|
|
|
final response = await iapCon.queryProductDetails(_generateSkus());
|
|
if (response.error != null) {
|
|
Log.e("IAP queryProductDetails: ${response.error}");
|
|
}
|
|
var products = response.productDetails;
|
|
products.sort((a, b) => a.price.compareTo(b.price));
|
|
Log.i("Products: ${products.map((e) => '${e.id} ${e.price}')}");
|
|
|
|
// 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;
|
|
|
|
setState(() {
|
|
_products = products;
|
|
_selectedProduct = _products.isNotEmpty ? _products.first : null;
|
|
|
|
if (_products.length > 1) {
|
|
for (var p in _products) {
|
|
if (p.id == defaultSku) {
|
|
_selectedProduct = p;
|
|
break;
|
|
}
|
|
}
|
|
} else {
|
|
// FIXME: Add a fake product for development
|
|
}
|
|
});
|
|
|
|
// Start listening for changes
|
|
final purchaseUpdates = iapCon.purchaseUpdatedStream;
|
|
_subscription = purchaseUpdates.listen(_listenToPurchaseUpdated);
|
|
}
|
|
|
|
void _listenToPurchaseUpdated(List<PurchaseDetails> purchaseDetailsList) {
|
|
purchaseDetailsList.forEach((PurchaseDetails purchaseDetails) async {
|
|
if (purchaseDetails.status == PurchaseStatus.pending) {
|
|
//showPendingUI();
|
|
Log.i("Pending - ${purchaseDetails.productID}");
|
|
setState(() {
|
|
pendingPurchase = true;
|
|
});
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
pendingPurchase = false;
|
|
});
|
|
if (purchaseDetails.status == PurchaseStatus.error) {
|
|
_handleIAPError(purchaseDetails.error);
|
|
} else if (purchaseDetails.status == PurchaseStatus.purchased) {
|
|
var subStatus = await verifyPurchase(purchaseDetails);
|
|
if (subStatus.isPro) {
|
|
_deliverProduct(subStatus);
|
|
} else {
|
|
_handleError("Failed to purchase product");
|
|
return;
|
|
}
|
|
}
|
|
if (Platform.isAndroid) {
|
|
await InAppPurchaseConnection.instance.consumePurchase(purchaseDetails);
|
|
}
|
|
if (purchaseDetails.pendingCompletePurchase) {
|
|
await InAppPurchaseConnection.instance
|
|
.completePurchase(purchaseDetails);
|
|
}
|
|
});
|
|
}
|
|
|
|
void _handleIAPError(IAPError err) {
|
|
var msg = "${err.code} - ${err.message} - ${err.details}";
|
|
_handleError(msg);
|
|
}
|
|
|
|
void _handleError(String err) {
|
|
var dialog = PurchaseFailedDialog(err);
|
|
showDialog(context: context, builder: (context) => dialog);
|
|
}
|
|
|
|
void _deliverProduct(SubscriptionStatus status) {
|
|
Settings.instance.proMode = status.isPro;
|
|
Settings.instance.proExpirationDate = status.expiryDate.toIso8601String();
|
|
Settings.instance.save();
|
|
|
|
getAnalytics().logEvent(
|
|
name: "purchase_screen_thank_you",
|
|
);
|
|
|
|
Navigator.of(context).popAndPushNamed('/purchase_thank_you');
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_subscription?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (error.isNotEmpty) {
|
|
return Text("Failed to load: $error");
|
|
}
|
|
if (pendingPurchase) {
|
|
return const CircularProgressIndicator();
|
|
}
|
|
return _products == null
|
|
? const CircularProgressIndicator()
|
|
: buildBody(context);
|
|
}
|
|
|
|
PaymentInfo _fromProductDetail(ProductDetails pd) {
|
|
if (pd == null) return null;
|
|
|
|
double value = -1;
|
|
if (pd.skProduct != null) {
|
|
value = double.parse(pd.skProduct.price);
|
|
} else if (pd.skuDetail != null) {
|
|
value = pd.skuDetail.originalPriceAmountMicros.toDouble() / 100000;
|
|
}
|
|
|
|
return PaymentInfo(
|
|
id: pd.id,
|
|
text: pd.price,
|
|
value: value,
|
|
);
|
|
}
|
|
|
|
ProductDetails _fromPaymentInfo(PaymentInfo info) {
|
|
for (var p in _products) {
|
|
if (p.id == info.id) {
|
|
return p;
|
|
}
|
|
}
|
|
assert(false);
|
|
return null;
|
|
}
|
|
|
|
Widget buildBody(BuildContext context) {
|
|
var slider = PurchaseSlider(
|
|
values: _products.map(_fromProductDetail).toList(),
|
|
selectedValue: _fromProductDetail(_selectedProduct),
|
|
onChanged: (PaymentInfo info) {
|
|
setState(() {
|
|
_selectedProduct = _fromPaymentInfo(info);
|
|
});
|
|
},
|
|
);
|
|
|
|
return Column(
|
|
children: <Widget>[
|
|
Row(
|
|
children: <Widget>[
|
|
_PurchaseSliderButton(
|
|
icon: const Icon(Icons.arrow_left),
|
|
onPressed: () {
|
|
setState(() {
|
|
_selectedProduct = _prevProduct();
|
|
});
|
|
},
|
|
),
|
|
Expanded(child: slider),
|
|
_PurchaseSliderButton(
|
|
icon: const Icon(Icons.arrow_right),
|
|
onPressed: () {
|
|
setState(() {
|
|
_selectedProduct = _nextProduct();
|
|
});
|
|
},
|
|
),
|
|
],
|
|
mainAxisSize: MainAxisSize.max,
|
|
),
|
|
const SizedBox(height: 16.0),
|
|
PurchaseButton(_selectedProduct),
|
|
],
|
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
|
);
|
|
}
|
|
|
|
ProductDetails _prevProduct() {
|
|
for (var i = 0; i < _products.length; i++) {
|
|
if (_products[i] == _selectedProduct) {
|
|
return i > 0 ? _products[i - 1] : _products[i];
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
ProductDetails _nextProduct() {
|
|
for (var i = 0; i < _products.length; i++) {
|
|
if (_products[i] == _selectedProduct) {
|
|
return i < _products.length - 1 ? _products[i + 1] : _products[i];
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|
|
|
|
class _PurchaseSliderButton extends StatelessWidget {
|
|
final Widget icon;
|
|
final Function onPressed;
|
|
|
|
_PurchaseSliderButton({@required this.icon, @required this.onPressed});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return IconButton(
|
|
icon: icon,
|
|
padding: const EdgeInsets.all(0.0),
|
|
iconSize: 64.0,
|
|
onPressed: onPressed,
|
|
);
|
|
}
|
|
}
|
|
|
|
class PurchaseFailedDialog extends StatelessWidget {
|
|
final String text;
|
|
|
|
PurchaseFailedDialog(this.text);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return AlertDialog(
|
|
title: const Text("Purchase Failed"),
|
|
content: Text(text),
|
|
actions: <Widget>[
|
|
FlatButton(
|
|
child: const Text("OK"),
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|