392 lines
12 KiB
Dart
392 lines
12 KiB
Dart
// Copyright 2019 The Chromium Authors. All rights reserved.
|
|
// Use of this source code is governed by a BSD-style license that can be
|
|
// found in the LICENSE file.
|
|
|
|
import 'dart:async';
|
|
import 'dart:io';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:in_app_purchase/in_app_purchase.dart';
|
|
import 'consumable_store.dart';
|
|
|
|
void main() {
|
|
// For play billing library 2.0 on Android, it is mandatory to call
|
|
// [enablePendingPurchases](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.Builder.html#enablependingpurchases)
|
|
// as part of initializing the app.
|
|
InAppPurchaseConnection.enablePendingPurchases();
|
|
runApp(_MyApp());
|
|
}
|
|
|
|
const bool _kAutoConsume = true;
|
|
|
|
const String _kConsumableId = 'consumable';
|
|
const List<String> _kProductIds = <String>[
|
|
_kConsumableId,
|
|
'upgrade',
|
|
'subscription'
|
|
];
|
|
|
|
class _MyApp extends StatefulWidget {
|
|
@override
|
|
_MyAppState createState() => _MyAppState();
|
|
}
|
|
|
|
class _MyAppState extends State<_MyApp> {
|
|
final InAppPurchaseConnection _connection = InAppPurchaseConnection.instance;
|
|
StreamSubscription<List<PurchaseDetails>> _subscription;
|
|
List<String> _notFoundIds = [];
|
|
List<ProductDetails> _products = [];
|
|
List<PurchaseDetails> _purchases = [];
|
|
List<String> _consumables = [];
|
|
bool _isAvailable = false;
|
|
bool _purchasePending = false;
|
|
bool _loading = true;
|
|
String _queryProductError;
|
|
|
|
@override
|
|
void initState() {
|
|
Stream purchaseUpdated =
|
|
InAppPurchaseConnection.instance.purchaseUpdatedStream;
|
|
_subscription = purchaseUpdated.listen((purchaseDetailsList) {
|
|
_listenToPurchaseUpdated(purchaseDetailsList);
|
|
}, onDone: () {
|
|
_subscription.cancel();
|
|
}, onError: (error) {
|
|
// handle error here.
|
|
});
|
|
initStoreInfo();
|
|
super.initState();
|
|
}
|
|
|
|
Future<void> initStoreInfo() async {
|
|
final bool isAvailable = await _connection.isAvailable();
|
|
if (!isAvailable) {
|
|
setState(() {
|
|
_isAvailable = isAvailable;
|
|
_products = [];
|
|
_purchases = [];
|
|
_notFoundIds = [];
|
|
_consumables = [];
|
|
_purchasePending = false;
|
|
_loading = false;
|
|
});
|
|
return;
|
|
}
|
|
|
|
ProductDetailsResponse productDetailResponse =
|
|
await _connection.queryProductDetails(_kProductIds.toSet());
|
|
if (productDetailResponse.error != null) {
|
|
setState(() {
|
|
_queryProductError = productDetailResponse.error.message;
|
|
_isAvailable = isAvailable;
|
|
_products = productDetailResponse.productDetails;
|
|
_purchases = [];
|
|
_notFoundIds = productDetailResponse.notFoundIDs;
|
|
_consumables = [];
|
|
_purchasePending = false;
|
|
_loading = false;
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (productDetailResponse.productDetails.isEmpty) {
|
|
setState(() {
|
|
_queryProductError = null;
|
|
_isAvailable = isAvailable;
|
|
_products = productDetailResponse.productDetails;
|
|
_purchases = [];
|
|
_notFoundIds = productDetailResponse.notFoundIDs;
|
|
_consumables = [];
|
|
_purchasePending = false;
|
|
_loading = false;
|
|
});
|
|
return;
|
|
}
|
|
|
|
final QueryPurchaseDetailsResponse purchaseResponse =
|
|
await _connection.queryPastPurchases();
|
|
if (purchaseResponse.error != null) {
|
|
// handle query past purchase error..
|
|
}
|
|
final List<PurchaseDetails> verifiedPurchases = [];
|
|
for (PurchaseDetails purchase in purchaseResponse.pastPurchases) {
|
|
if (await _verifyPurchase(purchase)) {
|
|
verifiedPurchases.add(purchase);
|
|
}
|
|
}
|
|
List<String> consumables = await ConsumableStore.load();
|
|
setState(() {
|
|
_isAvailable = isAvailable;
|
|
_products = productDetailResponse.productDetails;
|
|
_purchases = verifiedPurchases;
|
|
_notFoundIds = productDetailResponse.notFoundIDs;
|
|
_consumables = consumables;
|
|
_purchasePending = false;
|
|
_loading = false;
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_subscription.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
List<Widget> stack = [];
|
|
if (_queryProductError == null) {
|
|
stack.add(
|
|
ListView(
|
|
children: [
|
|
_buildConnectionCheckTile(),
|
|
_buildProductList(),
|
|
_buildConsumableBox(),
|
|
],
|
|
),
|
|
);
|
|
} else {
|
|
stack.add(Center(
|
|
child: Text(_queryProductError),
|
|
));
|
|
}
|
|
if (_purchasePending) {
|
|
stack.add(
|
|
Stack(
|
|
children: [
|
|
Opacity(
|
|
opacity: 0.3,
|
|
child: const ModalBarrier(dismissible: false, color: Colors.grey),
|
|
),
|
|
Center(
|
|
child: CircularProgressIndicator(),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
return MaterialApp(
|
|
home: Scaffold(
|
|
appBar: AppBar(
|
|
title: const Text('IAP Example'),
|
|
),
|
|
body: Stack(
|
|
children: stack,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Card _buildConnectionCheckTile() {
|
|
if (_loading) {
|
|
return Card(child: ListTile(title: const Text('Trying to connect...')));
|
|
}
|
|
final Widget storeHeader = ListTile(
|
|
leading: Icon(_isAvailable ? Icons.check : Icons.block,
|
|
color: _isAvailable ? Colors.green : ThemeData.light().errorColor),
|
|
title: Text(
|
|
'The store is ' + (_isAvailable ? 'available' : 'unavailable') + '.'),
|
|
);
|
|
final List<Widget> children = <Widget>[storeHeader];
|
|
|
|
if (!_isAvailable) {
|
|
children.addAll([
|
|
Divider(),
|
|
ListTile(
|
|
title: Text('Not connected',
|
|
style: TextStyle(color: ThemeData.light().errorColor)),
|
|
subtitle: const Text(
|
|
'Unable to connect to the payments processor. Has this app been configured correctly? See the example README for instructions.'),
|
|
),
|
|
]);
|
|
}
|
|
return Card(child: Column(children: children));
|
|
}
|
|
|
|
Card _buildProductList() {
|
|
if (_loading) {
|
|
return Card(
|
|
child: (ListTile(
|
|
leading: CircularProgressIndicator(),
|
|
title: Text('Fetching products...'))));
|
|
}
|
|
if (!_isAvailable) {
|
|
return Card();
|
|
}
|
|
final ListTile productHeader = ListTile(title: Text('Products for Sale'));
|
|
List<ListTile> productList = <ListTile>[];
|
|
if (_notFoundIds.isNotEmpty) {
|
|
productList.add(ListTile(
|
|
title: Text('[${_notFoundIds.join(", ")}] not found',
|
|
style: TextStyle(color: ThemeData.light().errorColor)),
|
|
subtitle: Text(
|
|
'This app needs special configuration to run. Please see example/README.md for instructions.')));
|
|
}
|
|
|
|
// This loading previous purchases code is just a demo. Please do not use this as it is.
|
|
// In your app you should always verify the purchase data using the `verificationData` inside the [PurchaseDetails] object before trusting it.
|
|
// We recommend that you use your own server to verify the purchase data.
|
|
Map<String, PurchaseDetails> purchases =
|
|
Map.fromEntries(_purchases.map((PurchaseDetails purchase) {
|
|
if (purchase.pendingCompletePurchase) {
|
|
InAppPurchaseConnection.instance.completePurchase(purchase);
|
|
}
|
|
return MapEntry<String, PurchaseDetails>(purchase.productID, purchase);
|
|
}));
|
|
productList.addAll(_products.map(
|
|
(ProductDetails productDetails) {
|
|
PurchaseDetails previousPurchase = purchases[productDetails.id];
|
|
return ListTile(
|
|
title: Text(
|
|
productDetails.title,
|
|
),
|
|
subtitle: Text(
|
|
productDetails.description,
|
|
),
|
|
trailing: previousPurchase != null
|
|
? Icon(Icons.check)
|
|
: TextButton(
|
|
child: Text(productDetails.price),
|
|
style: TextButton.styleFrom(
|
|
backgroundColor: Colors.green[800],
|
|
primary: Colors.white,
|
|
),
|
|
onPressed: () {
|
|
PurchaseParam purchaseParam = PurchaseParam(
|
|
productDetails: productDetails,
|
|
applicationUserName: null,
|
|
sandboxTesting: true);
|
|
if (productDetails.id == _kConsumableId) {
|
|
_connection.buyConsumable(
|
|
purchaseParam: purchaseParam,
|
|
autoConsume: _kAutoConsume || Platform.isIOS);
|
|
} else {
|
|
_connection.buyNonConsumable(
|
|
purchaseParam: purchaseParam);
|
|
}
|
|
},
|
|
));
|
|
},
|
|
));
|
|
|
|
return Card(
|
|
child:
|
|
Column(children: <Widget>[productHeader, Divider()] + productList));
|
|
}
|
|
|
|
Card _buildConsumableBox() {
|
|
if (_loading) {
|
|
return Card(
|
|
child: (ListTile(
|
|
leading: CircularProgressIndicator(),
|
|
title: Text('Fetching consumables...'))));
|
|
}
|
|
if (!_isAvailable || _notFoundIds.contains(_kConsumableId)) {
|
|
return Card();
|
|
}
|
|
final ListTile consumableHeader =
|
|
ListTile(title: Text('Purchased consumables'));
|
|
final List<Widget> tokens = _consumables.map((String id) {
|
|
return GridTile(
|
|
child: IconButton(
|
|
icon: Icon(
|
|
Icons.stars,
|
|
size: 42.0,
|
|
color: Colors.orange,
|
|
),
|
|
splashColor: Colors.yellowAccent,
|
|
onPressed: () => consume(id),
|
|
),
|
|
);
|
|
}).toList();
|
|
return Card(
|
|
child: Column(children: <Widget>[
|
|
consumableHeader,
|
|
Divider(),
|
|
GridView.count(
|
|
crossAxisCount: 5,
|
|
children: tokens,
|
|
shrinkWrap: true,
|
|
padding: EdgeInsets.all(16.0),
|
|
)
|
|
]));
|
|
}
|
|
|
|
Future<void> consume(String id) async {
|
|
await ConsumableStore.consume(id);
|
|
final List<String> consumables = await ConsumableStore.load();
|
|
setState(() {
|
|
_consumables = consumables;
|
|
});
|
|
}
|
|
|
|
void showPendingUI() {
|
|
setState(() {
|
|
_purchasePending = true;
|
|
});
|
|
}
|
|
|
|
void deliverProduct(PurchaseDetails purchaseDetails) async {
|
|
// IMPORTANT!! Always verify a purchase purchase details before delivering the product.
|
|
if (purchaseDetails.productID == _kConsumableId) {
|
|
await ConsumableStore.save(purchaseDetails.purchaseID);
|
|
List<String> consumables = await ConsumableStore.load();
|
|
setState(() {
|
|
_purchasePending = false;
|
|
_consumables = consumables;
|
|
});
|
|
} else {
|
|
setState(() {
|
|
_purchases.add(purchaseDetails);
|
|
_purchasePending = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
void handleError(IAPError error) {
|
|
setState(() {
|
|
_purchasePending = false;
|
|
});
|
|
}
|
|
|
|
Future<bool> _verifyPurchase(PurchaseDetails purchaseDetails) {
|
|
// IMPORTANT!! Always verify a purchase before delivering the product.
|
|
// For the purpose of an example, we directly return true.
|
|
return Future<bool>.value(true);
|
|
}
|
|
|
|
void _handleInvalidPurchase(PurchaseDetails purchaseDetails) {
|
|
// handle invalid purchase here if _verifyPurchase` failed.
|
|
}
|
|
|
|
void _listenToPurchaseUpdated(List<PurchaseDetails> purchaseDetailsList) {
|
|
purchaseDetailsList.forEach((PurchaseDetails purchaseDetails) async {
|
|
if (purchaseDetails.status == PurchaseStatus.pending) {
|
|
showPendingUI();
|
|
} else {
|
|
if (purchaseDetails.status == PurchaseStatus.error) {
|
|
handleError(purchaseDetails.error);
|
|
} else if (purchaseDetails.status == PurchaseStatus.purchased) {
|
|
bool valid = await _verifyPurchase(purchaseDetails);
|
|
if (valid) {
|
|
deliverProduct(purchaseDetails);
|
|
} else {
|
|
_handleInvalidPurchase(purchaseDetails);
|
|
return;
|
|
}
|
|
}
|
|
if (Platform.isAndroid) {
|
|
if (!_kAutoConsume && purchaseDetails.productID == _kConsumableId) {
|
|
await InAppPurchaseConnection.instance
|
|
.consumePurchase(purchaseDetails);
|
|
}
|
|
}
|
|
if (purchaseDetails.pendingCompletePurchase) {
|
|
await InAppPurchaseConnection.instance
|
|
.completePurchase(purchaseDetails);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|