In-app purchase

This commit is contained in:
Hillel Coren 2022-07-13 14:55:45 +03:00
parent d3b03a5ead
commit e9ac07f2ee
7 changed files with 670 additions and 252 deletions

View File

@ -92,18 +92,28 @@ const String kSharedPrefs = 'shared_prefs';
const String kSharedPrefUrl = 'url'; const String kSharedPrefUrl = 'url';
const String kSharedPrefToken = 'checksum'; const String kSharedPrefToken = 'checksum';
const String kProductPlanPro = 'v1_pro_yearly'; const String kProductProPlanMonth = 'pro_plan';
const String kProductPlanEnterprise2 = 'v1_enterprise_2_yearly'; const String kProductEnterprisePlanMonth_2 = 'enterprise_plan';
const String kProductPlanEnterprise5 = 'v1_enterprise_5_yearly'; const String kProductEnterprisePlanMonth_5 = 'enterprise_plan_5';
const String kProductPlanEnterprise10 = 'v1_enterprise_10_yearly'; const String kProductEnterprisePlanMonth_10 = 'enterprise_plan_10';
const String kProductPlanEnterprise20 = 'v1_enterprise_20_yearly'; const String kProductEnterprisePlanMonth_20 = 'enterprise_plan_20';
const String kProductProPlanYear = 'pro_plan_annual';
const String kProductEnterprisePlanYear_2 = 'enterprise_plan_annual';
const String kProductEnterprisePlanYear_5 = 'enterprise_plan_annual_5';
const String kProductEnterprisePlanYear_10 = 'enterprise_plan_annual_10';
const String kProductEnterprisePlanYear_20 = 'enterprise_plan_annual_20';
const kProductPlans = [ const kProductPlans = [
kProductPlanPro, kProductProPlanMonth,
kProductPlanEnterprise2, kProductEnterprisePlanMonth_2,
kProductPlanEnterprise5, kProductEnterprisePlanMonth_5,
kProductPlanEnterprise10, kProductEnterprisePlanMonth_10,
kProductPlanEnterprise20, kProductEnterprisePlanMonth_20,
kProductProPlanYear,
kProductEnterprisePlanYear_2,
kProductEnterprisePlanYear_5,
kProductEnterprisePlanYear_10,
kProductEnterprisePlanYear_20,
]; ];
const double kMobileLayoutWidth = 700; const double kMobileLayoutWidth = 700;

View File

@ -1,289 +1,403 @@
/* // Copyright 2013 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// Dart imports:
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'dart:io'; import 'dart:io';
// Flutter imports:
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/material.dart';
import 'package:flutter/material.dart';
// Package imports:
import 'package:flutter_redux/flutter_redux.dart'; import 'package:flutter_redux/flutter_redux.dart';
import 'package:in_app_purchase/in_app_purchase.dart'; import 'package:in_app_purchase/in_app_purchase.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:in_app_purchase_android/billing_client_wrappers.dart';
import 'package:in_app_purchase_android/in_app_purchase_android.dart';
// Project imports: import 'package:in_app_purchase_storekit/in_app_purchase_storekit.dart';
import 'package:in_app_purchase_storekit/store_kit_wrappers.dart';
import 'package:invoiceninja_flutter/constants.dart'; import 'package:invoiceninja_flutter/constants.dart';
import 'package:invoiceninja_flutter/data/web_client.dart';
import 'package:invoiceninja_flutter/redux/app/app_actions.dart';
import 'package:invoiceninja_flutter/redux/app/app_state.dart'; import 'package:invoiceninja_flutter/redux/app/app_state.dart';
import 'package:invoiceninja_flutter/ui/app/buttons/elevated_button.dart';
import 'package:invoiceninja_flutter/ui/app/dialogs/alert_dialog.dart';
import 'package:invoiceninja_flutter/ui/app/dialogs/error_dialog.dart';
import 'package:invoiceninja_flutter/ui/app/loading_indicator.dart';
import 'package:invoiceninja_flutter/utils/dialogs.dart';
import 'package:invoiceninja_flutter/utils/formatting.dart';
import 'package:invoiceninja_flutter/utils/localization.dart';
*/
import 'package:flutter/material.dart';
class UpgradeDialog extends StatelessWidget {
const UpgradeDialog({Key key}) : super(key: key);
@override
Widget build(BuildContext context) {
return SizedBox();
}
}
/*
class UpgradeDialog extends StatefulWidget { class UpgradeDialog extends StatefulWidget {
@override @override
_UpgradeDialogState createState() => _UpgradeDialogState(); State<UpgradeDialog> createState() => _UpgradeDialogState();
} }
class _UpgradeDialogState extends State<UpgradeDialog> { class _UpgradeDialogState extends State<UpgradeDialog> {
final InAppPurchase _inAppPurchase = InAppPurchase.instance;
StreamSubscription<List<PurchaseDetails>> _subscription; StreamSubscription<List<PurchaseDetails>> _subscription;
List<ProductDetails> _products; List<String> _notFoundIds = <String>[];
List<PurchaseDetails> _purchases; List<ProductDetails> _products = <ProductDetails>[];
bool _showPastPurchases = false; List<PurchaseDetails> _purchases = <PurchaseDetails>[];
bool _isAvailable = false;
Future<void> loadPurchases() async { bool _purchasePending = false;
InAppPurchaseConnection.instance bool _loading = true;
.queryPastPurchases() String _queryProductError;
.then((response) async {
if (response.pastPurchases != null && response.pastPurchases.isNotEmpty) {
setState(() {
_purchases = response.pastPurchases;
});
}
});
}
Future<void> redeemPurchase(PurchaseDetails purchase) async {
if (purchase.error != null || purchase.purchaseID == null) {
return null;
}
//Navigator.pop(context);
final localization = AppLocalization.of(context);
final store = StoreProvider.of<AppState>(context);
final state = store.state;
final webClient = WebClient();
final data = {
'order_id': purchase.purchaseID,
'product_id': purchase.productID,
'timestamp': (int.parse(purchase.transactionDate) / 1000).floor(),
};
try {
final dynamic response = await webClient
.post(
'$kAppProductionUrl/api/v1/upgrade',
state.credentials.token,
data: json.encode(data),
)
.catchError((dynamic error) {
showErrorDialog(context: context, message: error);
});
final String message = response['message'];
if (message == 'success') {
showDialog<MessageDialog>(
context: context,
builder: (BuildContext context) {
return MessageDialog(localization.thankYouForYourPurchase,
onDismiss: () {
store.dispatch(RefreshData());
});
});
if (Platform.isIOS) {
InAppPurchaseConnection.instance.completePurchase(purchase);
}
} else {
showDialog<ErrorDialog>(
context: context,
builder: (BuildContext context) {
return ErrorDialog(message);
});
}
} catch (error) {
showDialog<ErrorDialog>(
context: context,
builder: (BuildContext context) {
return ErrorDialog(error);
});
}
}
@override @override
void initState() { void initState() {
super.initState(); final Stream<List<PurchaseDetails>> purchaseUpdated =
_inAppPurchase.purchaseStream;
final Stream purchaseUpdates = _subscription =
InAppPurchaseConnection.instance.purchaseUpdatedStream; purchaseUpdated.listen((List<PurchaseDetails> purchaseDetailsList) {
_listenToPurchaseUpdated(purchaseDetailsList);
_subscription = purchaseUpdates.listen((dynamic purchases) {
(purchases as List<PurchaseDetails>).forEach((purchase) async {
await redeemPurchase(purchase);
});
}, onDone: () { }, onDone: () {
_subscription.cancel(); _subscription.cancel();
_subscription = null; }, onError: (Object error) {
}, onError: (dynamic error) { // handle error here.
showDialog<ErrorDialog>(
context: context,
builder: (BuildContext context) {
return ErrorDialog(error);
});
}); });
initStoreInfo();
initStore(); super.initState();
} }
void initStore() async { Future<void> initStoreInfo() async {
final bool available = await InAppPurchaseConnection.instance.isAvailable(); final bool isAvailable = await _inAppPurchase.isAvailable();
if (!isAvailable) {
if (!available) { setState(() {
showDialog<ErrorDialog>( _isAvailable = isAvailable;
context: context, _products = <ProductDetails>[];
builder: (BuildContext context) { _purchases = <PurchaseDetails>[];
return ErrorDialog('Store is not available'); _notFoundIds = <String>[];
}); _purchasePending = false;
_loading = false;
});
return; return;
} }
final productIds = Set<String>.from(kProductPlans); if (Platform.isIOS) {
final ProductDetailsResponse response = final InAppPurchaseStoreKitPlatformAddition iosPlatformAddition =
await InAppPurchaseConnection.instance.queryProductDetails(productIds); _inAppPurchase
.getPlatformAddition<InAppPurchaseStoreKitPlatformAddition>();
await iosPlatformAddition.setDelegate(ExamplePaymentQueueDelegate());
}
await loadPurchases(); final ProductDetailsResponse productDetailResponse =
await _inAppPurchase.queryProductDetails(kProductPlans.toSet());
if (productDetailResponse.error != null) {
setState(() {
_queryProductError = productDetailResponse.error.message;
_isAvailable = isAvailable;
_products = productDetailResponse.productDetails;
_purchases = <PurchaseDetails>[];
_notFoundIds = productDetailResponse.notFoundIDs;
_purchasePending = false;
_loading = false;
});
return;
}
if (productDetailResponse.productDetails.isEmpty) {
setState(() {
_queryProductError = null;
_isAvailable = isAvailable;
_products = productDetailResponse.productDetails;
_purchases = <PurchaseDetails>[];
_notFoundIds = productDetailResponse.notFoundIDs;
_purchasePending = false;
_loading = false;
});
return;
}
setState(() { setState(() {
_products = response.productDetails; _isAvailable = isAvailable;
_products = productDetailResponse.productDetails;
_notFoundIds = productDetailResponse.notFoundIDs;
_purchasePending = false;
_loading = false;
}); });
} }
@override @override
void dispose() { void dispose() {
if (Platform.isIOS) {
final InAppPurchaseStoreKitPlatformAddition iosPlatformAddition =
_inAppPurchase
.getPlatformAddition<InAppPurchaseStoreKitPlatformAddition>();
iosPlatformAddition.setDelegate(null);
}
_subscription.cancel(); _subscription.cancel();
super.dispose(); super.dispose();
} }
void upgrade(BuildContext context, ProductDetails productDetails) { @override
final store = StoreProvider.of<AppState>(context); Widget build(BuildContext context) {
final company = store.state.company; final List<Widget> stack = <Widget>[];
if (_queryProductError == null) {
stack.add(
ListView(
children: <Widget>[
_buildConnectionCheckTile(),
_buildProductList(),
_buildRestoreButton(),
],
),
);
} else {
stack.add(Center(
child: Text(_queryProductError),
));
}
if (_purchasePending) {
stack.add(
Stack(
children: const <Widget>[
Opacity(
opacity: 0.3,
child: ModalBarrier(dismissible: false, color: Colors.grey),
),
Center(
child: CircularProgressIndicator(),
),
],
),
);
}
InAppPurchaseConnection.instance.buyNonConsumable( return MaterialApp(
purchaseParam: PurchaseParam( home: Scaffold(
productDetails: productDetails, appBar: AppBar(
applicationUserName: company.companyKey, title: const Text('IAP Example'),
sandboxTesting: false, ),
)); body: Stack(
children: stack,
),
),
);
} }
String convertPlanToString(String plan) { Card _buildConnectionCheckTile() {
switch (plan) { if (_loading) {
case kProductPlanPro: return const Card(child: ListTile(title: Text('Trying to connect...')));
return 'Pro - 1 User';
case kProductPlanEnterprise2:
return 'Enterprise - 2 Users';
case kProductPlanEnterprise5:
return 'Enterprise - 5 Users';
case kProductPlanEnterprise10:
return 'Enterprise - 10 Users';
case kProductPlanEnterprise20:
return 'Enterprise - 20 Users';
default:
return '';
} }
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(<Widget>[
const 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 const Card(
child: ListTile(
leading: CircularProgressIndicator(),
title: Text('Fetching products...')));
}
if (!_isAvailable) {
return const Card();
}
const ListTile productHeader = ListTile(title: Text('Products for Sale'));
final List<ListTile> productList = <ListTile>[];
final store = StoreProvider.of<AppState>(context);
final account = store.state.account;
if (_notFoundIds.isNotEmpty) {
productList.add(ListTile(
title: Text('[${_notFoundIds.join(", ")}] not found',
style: TextStyle(color: ThemeData.light().errorColor)),
subtitle: const 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.
final Map<String, PurchaseDetails> purchases =
Map<String, PurchaseDetails>.fromEntries(
_purchases.map((PurchaseDetails purchase) {
if (purchase.pendingCompletePurchase) {
_inAppPurchase.completePurchase(purchase);
}
return MapEntry<String, PurchaseDetails>(purchase.productID, purchase);
}));
productList.addAll(_products.map(
(ProductDetails productDetails) {
final PurchaseDetails previousPurchase = purchases[productDetails.id];
return ListTile(
title: Text(
productDetails.title,
),
subtitle: Text(
productDetails.description,
),
trailing: previousPurchase != null
? IconButton(
onPressed: () => confirmPriceChange(context),
icon: const Icon(Icons.upgrade))
: TextButton(
style: TextButton.styleFrom(
backgroundColor: Colors.green[800],
// TODO(darrenaustin): Migrate to new API once it lands in stable: https://github.com/flutter/flutter/issues/105724
// ignore: deprecated_member_use
primary: Colors.white,
),
onPressed: () {
PurchaseParam purchaseParam;
if (Platform.isAndroid) {
purchaseParam = GooglePlayPurchaseParam(
productDetails: productDetails,
applicationUserName: account.id);
} else {
purchaseParam = PurchaseParam(
productDetails: productDetails,
applicationUserName: account.id,
);
}
_inAppPurchase.buyNonConsumable(
purchaseParam: purchaseParam,
);
},
child: Text(productDetails.price),
),
);
},
));
return Card(
child: Column(
children: <Widget>[productHeader, const Divider()] + productList));
}
Widget _buildRestoreButton() {
if (_loading) {
return Container();
}
return Padding(
padding: const EdgeInsets.all(4.0),
child: Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.end,
children: <Widget>[
TextButton(
style: TextButton.styleFrom(
backgroundColor: Theme.of(context).primaryColor,
// TODO(darrenaustin): Migrate to new API once it lands in stable: https://github.com/flutter/flutter/issues/105724
// ignore: deprecated_member_use
primary: Colors.white,
),
onPressed: () => _inAppPurchase.restorePurchases(),
child: const Text('Restore purchases'),
),
],
),
);
}
void showPendingUI() {
setState(() {
_purchasePending = true;
});
}
Future<void> deliverProduct(PurchaseDetails purchaseDetails) async {
// IMPORTANT!! Always verify purchase details before delivering the product.
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.
}
Future<void> _listenToPurchaseUpdated(
List<PurchaseDetails> purchaseDetailsList) async {
for (final PurchaseDetails purchaseDetails in purchaseDetailsList) {
if (purchaseDetails.status == PurchaseStatus.pending) {
showPendingUI();
} else {
if (purchaseDetails.status == PurchaseStatus.error) {
handleError(purchaseDetails.error);
} else if (purchaseDetails.status == PurchaseStatus.purchased ||
purchaseDetails.status == PurchaseStatus.restored) {
final bool valid = await _verifyPurchase(purchaseDetails);
if (valid) {
deliverProduct(purchaseDetails);
} else {
_handleInvalidPurchase(purchaseDetails);
return;
}
}
if (purchaseDetails.pendingCompletePurchase) {
await _inAppPurchase.completePurchase(purchaseDetails);
}
}
}
}
Future<void> confirmPriceChange(BuildContext context) async {
if (Platform.isAndroid) {
final InAppPurchaseAndroidPlatformAddition androidAddition =
_inAppPurchase
.getPlatformAddition<InAppPurchaseAndroidPlatformAddition>();
final BillingResultWrapper priceChangeConfirmationResult =
await androidAddition.launchPriceChangeConfirmationFlow(
sku: 'purchaseId',
);
if (priceChangeConfirmationResult.responseCode == BillingResponse.ok) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('Price change accepted'),
));
} else {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(
priceChangeConfirmationResult.debugMessage ??
'Price change failed with code ${priceChangeConfirmationResult.responseCode}',
),
));
}
}
if (Platform.isIOS) {
final InAppPurchaseStoreKitPlatformAddition iapStoreKitPlatformAddition =
_inAppPurchase
.getPlatformAddition<InAppPurchaseStoreKitPlatformAddition>();
await iapStoreKitPlatformAddition.showPriceConsentIfNeeded();
}
}
}
/// Example implementation of the
/// [`SKPaymentQueueDelegate`](https://developer.apple.com/documentation/storekit/skpaymentqueuedelegate?language=objc).
///
/// The payment queue delegate can be implementated to provide information
/// needed to complete transactions.
class ExamplePaymentQueueDelegate implements SKPaymentQueueDelegateWrapper {
@override
bool shouldContinueTransaction(
SKPaymentTransactionWrapper transaction, SKStorefrontWrapper storefront) {
return true;
} }
@override @override
Widget build(BuildContext context) { bool shouldShowPriceConsent() {
final localization = AppLocalization.of(context); return false;
if (_products == null) {
return LoadingIndicator(height: 50);
}
_products.sort((product1, product2) =>
parseDouble(product1.price) > parseDouble(product2.price) ? 1 : -1);
return SimpleDialog(
title: Column(
children: <Widget>[
Text(localization.annualSubscription),
if (Platform.isIOS)
Padding(
padding: const EdgeInsets.only(top: 8, bottom: 4),
child: Text(
'Payment will be charged to iTunes Account at confirmation of purchase. Subscription automatically renews unless auto-renew is turned off at least 24-hours before the end of the current period. Account will be charged for renewal within 24-hours prior to the end of the current period, and identify the cost of the renewal. Subscriptions may be managed by the user and auto-renewal may be turned off by going to the user\'s Account Settings after purchase.',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
TextButton(
child: Text('Terms', style: TextStyle(fontSize: 12)),
onPressed: () => launch(kTermsOfServiceURL),
),
TextButton(
child: Text('Privacy', style: TextStyle(fontSize: 12)),
onPressed: () => launch(kPrivacyPolicyURL),
),
],
)
],
),
contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 16),
children: [
if (_showPastPurchases)
..._purchases.map((purchase) => ListTile(
title: Text(purchase.purchaseID),
subtitle: Text(formatDate(
convertTimestampToDateString(
(int.parse(purchase.transactionDate) / 1000).floor()),
context)),
onTap: () => redeemPurchase(purchase),
)),
if (_purchases != null)
AppButton(
label: _showPastPurchases
? localization.back
: localization.pastPurchases,
onPressed: () {
setState(() {
_showPastPurchases = !_showPastPurchases;
if (_showPastPurchases) {
loadPurchases();
}
});
},
),
if (!_showPastPurchases)
..._products
.map((productDetails) => ListTile(
title: Text(productDetails.title ??
convertPlanToString(productDetails.id)),
subtitle: Text(productDetails.description ?? ''),
trailing: Text(productDetails.price ?? '',
style: TextStyle(fontSize: 18)),
onTap: () => upgrade(context, productDetails),
))
.toList()
],
);
} }
} }
*/

View File

@ -0,0 +1,263 @@
// Dart imports:
import 'dart:async';
import 'dart:convert';
import 'dart:io';
// Flutter imports:
import 'package:flutter/material.dart';
// Package imports:
import 'package:flutter_redux/flutter_redux.dart';
import 'package:in_app_purchase/in_app_purchase.dart';
import 'package:url_launcher/url_launcher.dart';
// Project imports:
import 'package:invoiceninja_flutter/constants.dart';
import 'package:invoiceninja_flutter/data/web_client.dart';
import 'package:invoiceninja_flutter/redux/app/app_actions.dart';
import 'package:invoiceninja_flutter/redux/app/app_state.dart';
import 'package:invoiceninja_flutter/ui/app/buttons/elevated_button.dart';
import 'package:invoiceninja_flutter/ui/app/dialogs/alert_dialog.dart';
import 'package:invoiceninja_flutter/ui/app/dialogs/error_dialog.dart';
import 'package:invoiceninja_flutter/ui/app/loading_indicator.dart';
import 'package:invoiceninja_flutter/utils/dialogs.dart';
import 'package:invoiceninja_flutter/utils/formatting.dart';
import 'package:invoiceninja_flutter/utils/localization.dart';
class UpgradeDialog extends StatefulWidget {
@override
_UpgradeDialogState createState() => _UpgradeDialogState();
}
class _UpgradeDialogState extends State<UpgradeDialog> {
StreamSubscription<List<PurchaseDetails>> _subscription;
List<ProductDetails> _products;
List<PurchaseDetails> _purchases;
bool _showPastPurchases = false;
Future<void> redeemPurchase(PurchaseDetails purchase) async {
if (purchase.error != null || purchase.purchaseID == null) {
return null;
}
//Navigator.pop(context);
final localization = AppLocalization.of(context);
final store = StoreProvider.of<AppState>(context);
final state = store.state;
final webClient = WebClient();
final data = {
'order_id': purchase.purchaseID,
'product_id': purchase.productID,
'timestamp': (int.parse(purchase.transactionDate) / 1000).floor(),
};
try {
final dynamic response = await webClient
.post(
'$kAppProductionUrl/api/v1/upgrade',
state.credentials.token,
data: json.encode(data),
)
.catchError((dynamic error) {
showErrorDialog(context: context, message: error);
});
final String message = response['message'];
if (message == 'success') {
showDialog<MessageDialog>(
context: context,
builder: (BuildContext context) {
return MessageDialog(localization.thankYouForYourPurchase,
onDismiss: () {
store.dispatch(RefreshData());
});
});
if (Platform.isIOS) {
InAppPurchase.instance.completePurchase(purchase);
}
} else {
showDialog<ErrorDialog>(
context: context,
builder: (BuildContext context) {
return ErrorDialog(message);
});
}
} catch (error) {
showDialog<ErrorDialog>(
context: context,
builder: (BuildContext context) {
return ErrorDialog(error);
});
}
}
@override
void initState() {
super.initState();
final Stream purchaseUpdates = InAppPurchase.instance.purchaseStream;
_subscription = purchaseUpdates.listen((dynamic purchases) {
(purchases as List<PurchaseDetails>).forEach((purchase) async {
await redeemPurchase(purchase);
});
}, onDone: () {
_subscription.cancel();
_subscription = null;
}, onError: (dynamic error) {
showDialog<ErrorDialog>(
context: context,
builder: (BuildContext context) {
return ErrorDialog(error);
});
});
initStore();
}
void initStore() async {
final bool available = await InAppPurchase.instance.isAvailable();
if (!available) {
showDialog<ErrorDialog>(
context: context,
builder: (BuildContext context) {
return ErrorDialog('Store is not available');
});
return;
}
final productIds = Set<String>.from(kProductPlans);
final ProductDetailsResponse response =
await InAppPurchase.instance.queryProductDetails(productIds);
setState(() {
_products = response.productDetails;
});
}
@override
void dispose() {
_subscription.cancel();
super.dispose();
}
void upgrade(BuildContext context, ProductDetails productDetails) {
final store = StoreProvider.of<AppState>(context);
final account = store.state.account;
InAppPurchase.instance.buyNonConsumable(
purchaseParam: PurchaseParam(
productDetails: productDetails,
applicationUserName: account.id,
));
}
String convertPlanToString(String plan) {
switch (plan) {
case kProductProPlanMonth:
return 'Pro - Month';
case kProductEnterprisePlanMonth_2:
return 'Enterprise - Month (1-2)';
case kProductEnterprisePlanMonth_5:
return 'Enterprise - Month (3-5)';
case kProductEnterprisePlanMonth_10:
return 'Enterprise - Month (6-10)';
case kProductEnterprisePlanMonth_20:
return 'Enterprise - Month (11-20)';
case kProductProPlanYear:
return 'Pro - Year';
case kProductEnterprisePlanYear_2:
return 'Enterprise - Year (1-2)';
case kProductEnterprisePlanYear_5:
return 'Enterprise - Year (3-5)';
case kProductEnterprisePlanYear_10:
return 'Enterprise - Year (6-10)';
case kProductEnterprisePlanYear_20:
return 'Enterprise - Year (11-20)';
default:
return '';
}
}
@override
Widget build(BuildContext context) {
final localization = AppLocalization.of(context);
if (_products == null) {
return LoadingIndicator(height: 50);
}
_products.sort((product1, product2) =>
parseDouble(product1.price) > parseDouble(product2.price) ? 1 : -1);
return SimpleDialog(
title: Column(
children: <Widget>[
Text(localization.annualSubscription),
if (Platform.isIOS)
Padding(
padding: const EdgeInsets.only(top: 8, bottom: 4),
child: Text(
'Payment will be charged to iTunes Account at confirmation of purchase. Subscription automatically renews unless auto-renew is turned off at least 24-hours before the end of the current period. Account will be charged for renewal within 24-hours prior to the end of the current period, and identify the cost of the renewal. Subscriptions may be managed by the user and auto-renewal may be turned off by going to the user\'s Account Settings after purchase.',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
TextButton(
child: Text('Terms', style: TextStyle(fontSize: 12)),
onPressed: () => launch(kTermsOfServiceURL),
),
TextButton(
child: Text('Privacy', style: TextStyle(fontSize: 12)),
onPressed: () => launch(kPrivacyPolicyURL),
),
],
)
],
),
contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 16),
children: [
if (_showPastPurchases)
..._purchases.map((purchase) => ListTile(
title: Text(purchase.purchaseID),
subtitle: Text(formatDate(
convertTimestampToDateString(
(int.parse(purchase.transactionDate) / 1000).floor()),
context)),
onTap: () => redeemPurchase(purchase),
)),
if (_purchases != null)
AppButton(
label: _showPastPurchases
? localization.back
: localization.pastPurchases,
onPressed: () {
setState(() {
_showPastPurchases = !_showPastPurchases;
if (_showPastPurchases) {
InAppPurchase.instance.restorePurchases();
}
});
},
),
if (!_showPastPurchases)
..._products
.map((productDetails) => ListTile(
title: Text(productDetails.title ??
convertPlanToString(productDetails.id)),
subtitle: Text(productDetails.description ?? ''),
trailing: Text(productDetails.price ?? '',
style: TextStyle(fontSize: 18)),
onTap: () => upgrade(context, productDetails),
))
.toList()
],
);
}
}

View File

@ -22,6 +22,7 @@ dependencies:
sdk: flutter sdk: flutter
# google_sign_in: ^5.0.7 # google_sign_in: ^5.0.7
# in_app_review: ^2.0.4 # in_app_review: ^2.0.4
in_app_purchase: ^3.0.6
flutter_redux: ^0.8.2 flutter_redux: ^0.8.2
redux_logging: ^0.5.0 redux_logging: ^0.5.0
http: ^0.13.3 http: ^0.13.3

View File

@ -570,6 +570,34 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "4.6.0" version: "4.6.0"
in_app_purchase:
dependency: "direct main"
description:
name: in_app_purchase
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.6"
in_app_purchase_android:
dependency: transitive
description:
name: in_app_purchase_android
url: "https://pub.dartlang.org"
source: hosted
version: "0.2.3"
in_app_purchase_platform_interface:
dependency: transitive
description:
name: in_app_purchase_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "1.3.1"
in_app_purchase_storekit:
dependency: transitive
description:
name: in_app_purchase_storekit
url: "https://pub.dartlang.org"
source: hosted
version: "0.3.1"
in_app_review: in_app_review:
dependency: "direct main" dependency: "direct main"
description: description:

View File

@ -22,6 +22,7 @@ dependencies:
sdk: flutter sdk: flutter
google_sign_in: ^5.0.7 google_sign_in: ^5.0.7
in_app_review: ^2.0.4 in_app_review: ^2.0.4
in_app_purchase: ^3.0.6
flutter_redux: ^0.8.2 flutter_redux: ^0.8.2
redux_logging: ^0.5.0 redux_logging: ^0.5.0
http: ^0.13.3 http: ^0.13.3

View File

@ -22,6 +22,7 @@ dependencies:
sdk: flutter sdk: flutter
google_sign_in: ^5.0.7 google_sign_in: ^5.0.7
in_app_review: ^2.0.4 in_app_review: ^2.0.4
in_app_purchase: ^3.0.6
flutter_redux: ^0.8.2 flutter_redux: ^0.8.2
redux_logging: ^0.5.0 redux_logging: ^0.5.0
http: ^0.13.3 http: ^0.13.3