395 lines
13 KiB
Dart
395 lines
13 KiB
Dart
// 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.
|
|
|
|
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_redux/flutter_redux.dart';
|
|
import 'package:in_app_purchase/in_app_purchase.dart';
|
|
import 'package:in_app_purchase_android/billing_client_wrappers.dart';
|
|
import 'package:in_app_purchase_android/in_app_purchase_android.dart';
|
|
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/data/web_client.dart';
|
|
import 'package:invoiceninja_flutter/main_app.dart';
|
|
import 'package:invoiceninja_flutter/redux/app/app_state.dart';
|
|
import 'package:invoiceninja_flutter/utils/localization.dart';
|
|
import 'package:url_launcher/url_launcher.dart';
|
|
|
|
class UpgradeDialog extends StatefulWidget {
|
|
@override
|
|
State<UpgradeDialog> createState() => _UpgradeDialogState();
|
|
}
|
|
|
|
class _UpgradeDialogState extends State<UpgradeDialog> {
|
|
final InAppPurchase _inAppPurchase = InAppPurchase.instance;
|
|
StreamSubscription<List<PurchaseDetails>> _subscription;
|
|
List<ProductDetails> _products = <ProductDetails>[];
|
|
List<PurchaseDetails> _purchases = <PurchaseDetails>[];
|
|
bool _isAvailable = false;
|
|
bool _purchasePending = false;
|
|
bool _loading = true;
|
|
String _queryProductError;
|
|
|
|
@override
|
|
void initState() {
|
|
final Stream<List<PurchaseDetails>> purchaseUpdated =
|
|
_inAppPurchase.purchaseStream;
|
|
_subscription =
|
|
purchaseUpdated.listen((List<PurchaseDetails> purchaseDetailsList) {
|
|
_listenToPurchaseUpdated(purchaseDetailsList);
|
|
}, onDone: () {
|
|
_subscription.cancel();
|
|
}, onError: (Object error) {
|
|
// handle error here.
|
|
});
|
|
initStoreInfo();
|
|
super.initState();
|
|
}
|
|
|
|
Future<void> initStoreInfo() async {
|
|
final bool isAvailable = await _inAppPurchase.isAvailable();
|
|
if (!isAvailable) {
|
|
setState(() {
|
|
_isAvailable = isAvailable;
|
|
_products = <ProductDetails>[];
|
|
_purchases = <PurchaseDetails>[];
|
|
_purchasePending = false;
|
|
_loading = false;
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (Platform.isIOS) {
|
|
final InAppPurchaseStoreKitPlatformAddition iosPlatformAddition =
|
|
_inAppPurchase
|
|
.getPlatformAddition<InAppPurchaseStoreKitPlatformAddition>();
|
|
await iosPlatformAddition.setDelegate(ExamplePaymentQueueDelegate());
|
|
}
|
|
|
|
final ProductDetailsResponse productDetailResponse =
|
|
await _inAppPurchase.queryProductDetails(kProductPlans.toSet());
|
|
if (productDetailResponse.error != null) {
|
|
setState(() {
|
|
_queryProductError = productDetailResponse.error.message;
|
|
_isAvailable = isAvailable;
|
|
_products = productDetailResponse.productDetails;
|
|
_purchases = <PurchaseDetails>[];
|
|
_purchasePending = false;
|
|
_loading = false;
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (productDetailResponse.productDetails.isEmpty) {
|
|
setState(() {
|
|
_queryProductError = null;
|
|
_isAvailable = isAvailable;
|
|
_products = productDetailResponse.productDetails;
|
|
_purchases = <PurchaseDetails>[];
|
|
_purchasePending = false;
|
|
_loading = false;
|
|
});
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
_isAvailable = isAvailable;
|
|
_products = productDetailResponse.productDetails;
|
|
_purchasePending = false;
|
|
_loading = false;
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
if (Platform.isIOS) {
|
|
final InAppPurchaseStoreKitPlatformAddition iosPlatformAddition =
|
|
_inAppPurchase
|
|
.getPlatformAddition<InAppPurchaseStoreKitPlatformAddition>();
|
|
iosPlatformAddition.setDelegate(null);
|
|
}
|
|
_subscription.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final localization = AppLocalization.of(context);
|
|
final List<Widget> stack = <Widget>[];
|
|
if (_queryProductError == null) {
|
|
stack.add(
|
|
ListView(
|
|
children: <Widget>[
|
|
if (Platform.isIOS)
|
|
Padding(
|
|
padding: const EdgeInsets.only(bottom: 16),
|
|
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),
|
|
),
|
|
),
|
|
_buildProductList(),
|
|
],
|
|
),
|
|
);
|
|
} 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(),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
return AlertDialog(
|
|
title: Text(localization.upgrade),
|
|
content: Column(
|
|
children: [
|
|
Expanded(child: Stack(children: stack)),
|
|
],
|
|
),
|
|
actions: [
|
|
if (!_loading)
|
|
TextButton(
|
|
onPressed: () {
|
|
_inAppPurchase.restorePurchases();
|
|
},
|
|
child: Text(localization.restorePurchases)),
|
|
TextButton(
|
|
child: Text(localization.termsOfService),
|
|
onPressed: () => launch(kTermsOfServiceURL),
|
|
),
|
|
TextButton(
|
|
child: Text(localization.privacyPolicy),
|
|
onPressed: () => launch(kPrivacyPolicyURL),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildProductList() {
|
|
if (_loading) {
|
|
return const Card(
|
|
child: ListTile(
|
|
leading: CircularProgressIndicator(),
|
|
title: Text('Fetching products...')));
|
|
}
|
|
if (!_isAvailable) {
|
|
return const Card();
|
|
}
|
|
final List<ListTile> productList = <ListTile>[];
|
|
final store = StoreProvider.of<AppState>(context);
|
|
final account = store.state.account;
|
|
|
|
// 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);
|
|
}));
|
|
_products.sort((p1, p2) => p1.rawPrice.compareTo(p2.rawPrice));
|
|
productList.addAll(_products.map(
|
|
(ProductDetails productDetails) {
|
|
final PurchaseDetails previousPurchase = purchases[productDetails.id];
|
|
|
|
String description = productDetails.description;
|
|
|
|
// TODO remove this code
|
|
// Workaround for product in app store with blank values
|
|
if (description.isEmpty &&
|
|
productDetails.id == kProductEnterprisePlanMonth_10) {
|
|
description = 'One month of the Enterprise Plan (10 users)';
|
|
}
|
|
|
|
return ListTile(
|
|
title: Text(description),
|
|
subtitle: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
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: () {
|
|
if (previousPurchase != null) {
|
|
confirmPriceChange(context);
|
|
} else {
|
|
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(previousPurchase != null
|
|
? AppLocalization.of(context).activate
|
|
: productDetails.price),
|
|
),
|
|
SizedBox(height: 20),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
));
|
|
|
|
return Column(children: productList);
|
|
}
|
|
|
|
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;
|
|
});
|
|
|
|
print('## PLAN UNLOCKED');
|
|
print('## ${purchaseDetails.purchaseID}');
|
|
print('## ${purchaseDetails.productID}');
|
|
|
|
final store = StoreProvider.of<AppState>(context);
|
|
final state = store.state;
|
|
final url = (state.isStaging ? kAppStagingUrl : kAppProductionUrl) +
|
|
'/admin/subscription';
|
|
|
|
await WebClient().post(url, state.credentials.token,
|
|
data: jsonEncode({
|
|
'inapp_transaction_id': purchaseDetails.purchaseID,
|
|
'account_id': state.account.id,
|
|
'plan': purchaseDetails.productID,
|
|
'plan_paid':
|
|
(int.parse(purchaseDetails.transactionDate) / 1000).floor(),
|
|
}));
|
|
}
|
|
|
|
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(navigatorKey.currentContext)
|
|
.showSnackBar(const SnackBar(
|
|
content: Text('Price change accepted'),
|
|
));
|
|
} else {
|
|
ScaffoldMessenger.of(navigatorKey.currentContext).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
|
|
bool shouldShowPriceConsent() {
|
|
return false;
|
|
}
|
|
}
|