In-app purchase
This commit is contained in:
parent
d3b03a5ead
commit
e9ac07f2ee
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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()
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
*/
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
28
pubspec.lock
28
pubspec.lock
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue