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

Abstract out the PurchaseWidget and use it for both monthly and yearly purchases. The yearly purchases hasn't been enabled so far, as the IAPs still need to be approved on iOS and the PageView widget I was using to switch between the two options isn't giving me what I want.
359 lines
9.4 KiB
Dart
359 lines
9.4 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
import 'package:easy_localization/easy_localization.dart';
|
|
import 'package:in_app_purchase/in_app_purchase.dart';
|
|
import 'package:provider/provider.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;
|
|
final String timePeriod;
|
|
|
|
PurchaseButton(this.product, this.timePeriod);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
var price = product != null ? product.price : "Dev Mode";
|
|
|
|
return RaisedButton(
|
|
child: Text('Subscribe for $price / $timePeriod'),
|
|
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;
|
|
}
|
|
}
|
|
|
|
class PurchaseWidget extends StatefulWidget {
|
|
final Set<String> skus;
|
|
final String defaultSku;
|
|
final String timePeriod;
|
|
|
|
PurchaseWidget({
|
|
@required this.skus,
|
|
@required this.defaultSku,
|
|
@required this.timePeriod,
|
|
});
|
|
|
|
@override
|
|
_PurchaseWidgetState createState() => _PurchaseWidgetState();
|
|
}
|
|
|
|
class _PurchaseWidgetState extends State<PurchaseWidget> {
|
|
List<ProductDetails> _products;
|
|
ProductDetails _selectedProduct;
|
|
StreamSubscription<List<PurchaseDetails>> _subscription;
|
|
|
|
String error = "";
|
|
bool pendingPurchase = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
initPlatformState();
|
|
}
|
|
|
|
Future<void> initPlatformState() async {
|
|
InAppPurchaseConnection.enablePendingPurchases();
|
|
final iapCon = InAppPurchaseConnection.instance;
|
|
|
|
final bool available = await iapCon.isAvailable();
|
|
if (!available) {
|
|
setState(() {
|
|
error = "Store cannot be reached";
|
|
});
|
|
return;
|
|
}
|
|
|
|
final response = await iapCon.queryProductDetails(widget.skus);
|
|
if (response.error != null) {
|
|
Log.e("IAP queryProductDetails: ${response.error}");
|
|
}
|
|
var products = response.productDetails;
|
|
products.sort((a, b) {
|
|
var pa = _fromProductDetail(a);
|
|
var pb = _fromProductDetail(b);
|
|
return pa.value.compareTo(pb.value);
|
|
});
|
|
Log.i("Products: ${products.length}");
|
|
for (var p in products) {
|
|
Log.i("Product ${p.id} -> ${p.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 == widget.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) {
|
|
var settings = Provider.of<Settings>(context);
|
|
settings.proMode = status.isPro;
|
|
settings.proExpirationDate = status.expiryDate.toIso8601String();
|
|
settings.save();
|
|
|
|
logEvent(Event.PurchaseScreenThankYou);
|
|
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, widget.timePeriod),
|
|
],
|
|
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(),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class RestorePurchaseButton extends StatefulWidget {
|
|
@override
|
|
_RestorePurchaseButtonState createState() => _RestorePurchaseButtonState();
|
|
}
|
|
|
|
class _RestorePurchaseButtonState extends State<RestorePurchaseButton> {
|
|
bool computing = false;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
var text = computing ? '...' : tr('purchase_screen.restore');
|
|
|
|
return OutlineButton(
|
|
child: Text(text),
|
|
onPressed: () async {
|
|
setState(() {
|
|
computing = true;
|
|
});
|
|
await InAppPurchases.confirmProPurchase();
|
|
if (Settings.instance.proMode) {
|
|
Navigator.of(context).pop();
|
|
}
|
|
},
|
|
);
|
|
}
|
|
}
|