ente/lib/ui/subscription_page.dart

685 lines
21 KiB
Dart
Raw Normal View History

2021-01-08 17:13:10 +00:00
import 'dart:async';
2021-02-03 14:24:19 +00:00
import 'dart:convert';
2021-01-06 16:48:48 +00:00
import 'dart:io';
import 'package:flutter/cupertino.dart';
2021-01-06 16:09:42 +00:00
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:in_app_purchase/in_app_purchase.dart';
2021-01-08 17:13:10 +00:00
import 'package:logging/logging.dart';
2021-02-05 11:10:05 +00:00
import 'package:photos/ui/expansion_card.dart';
import 'package:photos/utils/date_time_util.dart';
2021-02-03 14:24:19 +00:00
import 'package:progress_dialog/progress_dialog.dart';
import 'package:url_launcher/url_launcher.dart';
2021-01-10 20:49:22 +00:00
import 'package:photos/core/event_bus.dart';
2021-02-03 14:24:19 +00:00
import 'package:photos/core/network.dart';
2021-02-02 16:35:38 +00:00
import 'package:photos/events/subscription_purchased_event.dart';
2021-01-06 16:09:42 +00:00
import 'package:photos/models/billing_plan.dart';
2021-01-30 18:26:32 +00:00
import 'package:photos/models/subscription.dart';
2021-01-06 16:09:42 +00:00
import 'package:photos/services/billing_service.dart';
import 'package:photos/ui/loading_widget.dart';
import 'package:photos/utils/data_util.dart';
2021-01-06 16:48:48 +00:00
import 'package:photos/utils/dialog_util.dart';
import 'package:photos/utils/toast_util.dart';
2021-01-06 16:09:42 +00:00
2021-01-08 17:13:10 +00:00
class SubscriptionPage extends StatefulWidget {
final bool isOnboarding;
const SubscriptionPage({
this.isOnboarding = false,
Key key,
}) : super(key: key);
2021-01-06 16:09:42 +00:00
2021-01-08 17:13:10 +00:00
@override
_SubscriptionPageState createState() => _SubscriptionPageState();
}
class _SubscriptionPageState extends State<SubscriptionPage> {
2021-01-18 17:05:01 +00:00
final _logger = Logger("SubscriptionPage");
2021-01-30 18:26:32 +00:00
final _billingService = BillingService.instance;
Subscription _currentSubscription;
2021-01-08 17:13:10 +00:00
StreamSubscription _purchaseUpdateSubscription;
ProgressDialog _dialog;
Future<int> _usageFuture;
bool _hasActiveSubscription;
2021-01-08 17:13:10 +00:00
@override
void initState() {
2021-01-30 18:26:32 +00:00
_billingService.setIsOnSubscriptionPage(true);
_currentSubscription = _billingService.getSubscription();
_hasActiveSubscription =
_currentSubscription != null && _currentSubscription.isValid();
2021-02-02 16:35:38 +00:00
if (_currentSubscription != null) {
_usageFuture = _billingService.fetchUsage();
}
2021-01-30 07:57:18 +00:00
2021-01-30 08:41:05 +00:00
_dialog = createProgressDialog(context, "please wait...");
_purchaseUpdateSubscription = InAppPurchaseConnection
.instance.purchaseUpdatedStream
.listen((purchases) async {
if (!_dialog.isShowing()) {
await _dialog.show();
}
for (final purchase in purchases) {
_logger.info("Purchase status " + purchase.status.toString());
if (purchase.status == PurchaseStatus.purchased) {
try {
final newSubscription = await _billingService.verifySubscription(
purchase.productID,
purchase.verificationData.serverVerificationData,
);
await InAppPurchaseConnection.instance.completePurchase(purchase);
2021-02-02 16:35:38 +00:00
Bus.instance.fire(SubscriptionPurchasedEvent());
String text = "your photos and videos will now be backed up";
2021-03-02 19:36:43 +00:00
if (!widget.isOnboarding) {
final isUpgrade = _hasActiveSubscription &&
newSubscription.storage > _currentSubscription.storage;
final isDowngrade = _hasActiveSubscription &&
newSubscription.storage < _currentSubscription.storage;
if (isUpgrade) {
text = "your plan was successfully upgraded";
} else if (isDowngrade) {
text = "your plan was successfully downgraded";
}
}
showToast(text);
if (_currentSubscription != null) {
_currentSubscription = _billingService.getSubscription();
_hasActiveSubscription = _currentSubscription != null &&
_currentSubscription.isValid();
setState(() {});
} else {
Navigator.of(context).popUntil((route) => route.isFirst);
}
await _dialog.hide();
} catch (e) {
_logger.warning("Could not complete payment ", e);
await _dialog.hide();
showErrorDialog(
context,
"payment failed",
"please talk to " +
(Platform.isAndroid ? "PlayStore" : "AppStore") +
" support if you were charged");
return;
}
} else if (Platform.isIOS && purchase.pendingCompletePurchase) {
await InAppPurchaseConnection.instance.completePurchase(purchase);
await _dialog.hide();
2021-01-30 18:26:32 +00:00
}
2021-01-08 17:13:10 +00:00
}
});
super.initState();
}
@override
void dispose() {
_purchaseUpdateSubscription.cancel();
2021-01-30 18:26:32 +00:00
_billingService.setIsOnSubscriptionPage(false);
2021-01-08 17:13:10 +00:00
super.dispose();
}
2021-01-06 16:09:42 +00:00
@override
Widget build(BuildContext context) {
final appBar = AppBar(
title: Text("subscription"),
2021-01-06 16:09:42 +00:00
);
return Scaffold(
appBar: appBar,
body: _getBody(appBar.preferredSize.height),
);
}
Widget _getBody(final appBarSize) {
2021-03-02 06:35:10 +00:00
return FutureBuilder<BillingPlans>(
2021-01-30 18:26:32 +00:00
future: _billingService.getBillingPlans(),
2021-01-06 16:09:42 +00:00
builder: (BuildContext context, AsyncSnapshot snapshot) {
if (snapshot.hasData) {
return _buildPlans(context, snapshot.data, appBarSize);
} else if (snapshot.hasError) {
return Text("Oops, something went wrong.");
} else {
return loadWidget;
}
},
);
}
Widget _buildPlans(
2021-03-02 06:35:10 +00:00
BuildContext context, BillingPlans plans, final appBarSize) {
2021-01-06 16:09:42 +00:00
final planWidgets = List<Widget>();
2021-03-02 06:35:10 +00:00
for (final plan in plans.plans) {
final productID = Platform.isAndroid ? plan.androidID : plan.iosID;
if (productID == null || productID.isEmpty) {
continue;
}
final isActive =
_hasActiveSubscription && _currentSubscription.productID == productID;
2021-01-06 16:48:48 +00:00
planWidgets.add(
Material(
child: InkWell(
onTap: () async {
if (isActive) {
return;
}
await _dialog.show();
if (_usageFuture != null) {
final usage = await _usageFuture;
if (usage > plan.storage) {
await _dialog.hide();
showErrorDialog(
context, "sorry", "you cannot downgrade to this plan");
return;
}
}
final ProductDetailsResponse response =
await InAppPurchaseConnection.instance
.queryProductDetails([productID].toSet());
if (response.notFoundIDs.isNotEmpty) {
2021-03-01 18:42:37 +00:00
_logger.severe("Could not find products: " +
response.notFoundIDs.toString());
await _dialog.hide();
2021-01-06 16:48:48 +00:00
showGenericErrorDialog(context);
return;
}
2021-02-02 16:35:38 +00:00
final isCrossGradingOnAndroid = Platform.isAndroid &&
_hasActiveSubscription &&
_currentSubscription.productID != kFreeProductID &&
2021-02-02 16:35:38 +00:00
_currentSubscription.productID != plan.androidID;
if (isCrossGradingOnAndroid) {
final existingProductDetailsResponse =
await InAppPurchaseConnection.instance.queryProductDetails(
[_currentSubscription.productID].toSet());
if (existingProductDetailsResponse.notFoundIDs.isNotEmpty) {
2021-03-01 18:42:37 +00:00
_logger.severe("Could not find existing products: " +
response.notFoundIDs.toString());
await _dialog.hide();
showGenericErrorDialog(context);
return;
}
final subscriptionChangeParam = ChangeSubscriptionParam(
oldPurchaseDetails: PurchaseDetails(
purchaseID: null,
productID: _currentSubscription.productID,
verificationData: null,
transactionDate: null,
),
);
await InAppPurchaseConnection.instance.buyNonConsumable(
purchaseParam: PurchaseParam(
productDetails: response.productDetails[0],
changeSubscriptionParam: subscriptionChangeParam,
),
);
} else {
await InAppPurchaseConnection.instance.buyNonConsumable(
purchaseParam: PurchaseParam(
productDetails: response.productDetails[0],
),
);
}
2021-01-06 16:48:48 +00:00
},
child: SubscriptionPlanWidget(
plan: plan,
isActive: isActive,
),
2021-01-06 16:48:48 +00:00
),
),
);
2021-01-06 16:09:42 +00:00
}
final pageSize = MediaQuery.of(context).size.height;
final notifySize = MediaQuery.of(context).padding.top;
final widgets = List<Widget>();
if (_currentSubscription == null ||
_currentSubscription.productID == kFreeProductID) {
widgets.add(Padding(
padding: const EdgeInsets.fromLTRB(12, 20, 12, 24),
child: Text(
"ente encrypts and backs up your memories, so they're always available, even if you lose your device",
style: TextStyle(
color: Colors.white54,
height: 1.2,
),
),
));
} else {
widgets.add(Container(
height: 50,
child: FutureBuilder(
future: _usageFuture,
builder: (BuildContext context, AsyncSnapshot snapshot) {
if (snapshot.hasData) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Text("current usage is " +
convertBytesToGBs(snapshot.data).toString() +
" GB"),
);
} else if (snapshot.hasError) {
return Container();
} else {
return Padding(
padding: const EdgeInsets.all(16.0),
child: loadWidget,
);
}
},
),
));
}
widgets.addAll([
Column(
mainAxisAlignment: MainAxisAlignment.center,
children: planWidgets,
),
Padding(padding: EdgeInsets.all(8)),
2021-01-31 21:08:26 +00:00
]);
if (_hasActiveSubscription &&
_currentSubscription.productID != kFreeProductID) {
2021-01-31 21:08:26 +00:00
widgets.addAll([
2021-02-01 08:48:13 +00:00
Expanded(child: Container()),
Align(
alignment: Alignment.center,
child: GestureDetector(
onTap: () {
2021-02-03 14:24:19 +00:00
if (Platform.isAndroid) {
launch(
"https://play.google.com/store/account/subscriptions?sku=" +
_currentSubscription.productID +
"&package=io.ente.photos");
} else {
launch("https://apps.apple.com/account/billing");
}
2021-02-01 08:48:13 +00:00
},
child: Container(
padding: EdgeInsets.all(80),
child: Column(
children: [
Text(
"next renewal on " +
getDateAndMonthAndYear(
DateTime.fromMicrosecondsSinceEpoch(
_currentSubscription.expiryTime)),
style: TextStyle(
color: Colors.white.withOpacity(0.6),
fontSize: 14,
),
2021-02-01 08:48:13 +00:00
),
Padding(padding: EdgeInsets.all(8)),
RichText(
text: TextSpan(
text: "payment details",
style: TextStyle(
color: Colors.blue,
fontFamily: 'Ubuntu',
fontSize: 15,
),
),
),
],
2021-02-01 08:48:13 +00:00
),
),
),
),
2021-01-31 21:08:26 +00:00
]);
2021-02-01 08:48:13 +00:00
} else {
2021-02-05 11:10:05 +00:00
widgets.addAll([
2021-02-01 08:48:13 +00:00
Align(
alignment: Alignment.center,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
2021-02-01 08:48:13 +00:00
onTap: () {
2021-02-03 14:24:19 +00:00
showModalBottomSheet<void>(
backgroundColor: Colors.grey[900],
barrierColor: Colors.black87,
context: context,
builder: (context) {
return BillingQuestionsWidget();
},
);
2021-02-01 08:48:13 +00:00
},
child: Container(
padding: EdgeInsets.all(40),
2021-02-01 08:48:13 +00:00
child: RichText(
text: TextSpan(
2021-02-03 14:24:19 +00:00
text: "questions?",
2021-02-01 08:48:13 +00:00
style: TextStyle(
color: Colors.blue,
fontFamily: 'Ubuntu',
),
),
),
),
),
),
Expanded(child: Container()),
2021-02-01 08:48:13 +00:00
]);
}
if (widget.isOnboarding &&
(_currentSubscription == null ||
_currentSubscription.productID == kFreeProductID)) {
2021-03-02 06:35:10 +00:00
widgets.addAll([_getSkipButton(plans.freePlan)]);
}
2021-01-06 16:09:42 +00:00
return SingleChildScrollView(
child: Container(
height: pageSize - (appBarSize + notifySize),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: widgets,
2021-01-06 16:09:42 +00:00
),
),
);
}
2021-03-02 06:35:10 +00:00
GestureDetector _getSkipButton(FreePlan plan) {
return GestureDetector(
behavior: HitTestBehavior.translucent,
child: Container(
padding: EdgeInsets.fromLTRB(40, 20, 40, 20),
margin: EdgeInsets.only(bottom: 40),
child: Column(
children: [
Icon(
Icons.fast_forward_outlined,
color: Colors.white.withOpacity(0.8),
),
Text(
"skip",
style: TextStyle(
fontSize: 12,
color: Colors.white.withOpacity(0.8),
),
),
],
),
),
onTap: () {
AlertDialog alert = AlertDialog(
title: Text("sure?"),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
2021-03-02 06:35:10 +00:00
"you will only be able to backup " +
convertBytesToReadableFormat(plan.storage) +
" for the next " +
plan.duration.toString() +
" " +
plan.period,
style: TextStyle(
height: 1.4,
),
),
Padding(padding: EdgeInsets.all(8)),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
FlatButton(
child: Text("review plans"),
onPressed: () {
Navigator.of(context).pop();
},
),
FlatButton(
child: Text("ok"),
onPressed: () {
if (widget.isOnboarding) {
Bus.instance.fire(SubscriptionPurchasedEvent());
}
Navigator.of(context).popUntil((route) => route.isFirst);
},
),
],
),
],
),
);
showDialog(
context: context,
builder: (BuildContext context) {
return alert;
},
);
},
);
}
2021-01-06 16:09:42 +00:00
}
2021-02-03 14:24:19 +00:00
class BillingQuestionsWidget extends StatefulWidget {
const BillingQuestionsWidget({
2021-01-08 07:10:30 +00:00
Key key,
}) : super(key: key);
@override
2021-02-03 14:24:19 +00:00
_BillingQuestionsWidgetState createState() => _BillingQuestionsWidgetState();
}
2021-02-03 14:24:19 +00:00
class _BillingQuestionsWidgetState extends State<BillingQuestionsWidget> {
2021-01-08 07:10:30 +00:00
@override
Widget build(BuildContext context) {
2021-02-03 14:24:19 +00:00
return FutureBuilder(
future: Network.instance
.getDio()
.get("https://static.ente.io/faq.json")
.then((response) {
final faqItems = List<FaqItem>();
for (final item in response.data as List) {
faqItems.add(FaqItem.fromMap(item));
}
return faqItems;
}),
builder: (BuildContext context, AsyncSnapshot snapshot) {
if (snapshot.hasData) {
final faqs = List<Widget>();
faqs.add(Padding(
padding: const EdgeInsets.all(24),
child: Text(
"faqs",
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
2021-01-08 07:10:30 +00:00
),
),
2021-02-03 14:24:19 +00:00
));
for (final faq in snapshot.data) {
2021-02-05 11:10:05 +00:00
faqs.add(FaqWidget(faq: faq));
2021-02-03 14:24:19 +00:00
}
faqs.add(Padding(
padding: EdgeInsets.all(16),
));
return Container(
child: SingleChildScrollView(
child: Column(
children: faqs,
),
2021-01-08 07:10:30 +00:00
),
2021-02-03 14:24:19 +00:00
);
} else {
return loadWidget;
}
},
);
}
}
2021-02-05 11:10:05 +00:00
class FaqWidget extends StatelessWidget {
const FaqWidget({
Key key,
@required this.faq,
}) : super(key: key);
final faq;
@override
Widget build(BuildContext context) {
return ExpansionCard(
title: Text(faq.q),
color: Theme.of(context).accentColor,
children: [
Padding(
padding: const EdgeInsets.only(left: 16, right: 16),
child: Text(
faq.a,
style: TextStyle(
height: 1.5,
),
),
)
],
);
}
}
2021-02-03 14:24:19 +00:00
class FaqItem {
final String q;
final String a;
FaqItem({
this.q,
this.a,
});
FaqItem copyWith({
String q,
String a,
}) {
return FaqItem(
q: q ?? this.q,
a: a ?? this.a,
2021-01-08 07:10:30 +00:00
);
}
2021-02-03 14:24:19 +00:00
Map<String, dynamic> toMap() {
return {
'q': q,
'a': a,
};
}
factory FaqItem.fromMap(Map<String, dynamic> map) {
if (map == null) return null;
return FaqItem(
q: map['q'],
a: map['a'],
);
}
String toJson() => json.encode(toMap());
factory FaqItem.fromJson(String source) =>
FaqItem.fromMap(json.decode(source));
@override
String toString() => 'FaqItem(q: $q, a: $a)';
@override
bool operator ==(Object o) {
if (identical(this, o)) return true;
return o is FaqItem && o.q == q && o.a == a;
}
@override
int get hashCode => q.hashCode ^ a.hashCode;
2021-01-08 07:10:30 +00:00
}
2021-01-06 16:09:42 +00:00
class SubscriptionPlanWidget extends StatelessWidget {
const SubscriptionPlanWidget({
Key key,
@required this.plan,
this.isActive = false,
2021-01-06 16:09:42 +00:00
}) : super(key: key);
final BillingPlan plan;
final bool isActive;
2021-01-06 16:09:42 +00:00
@override
Widget build(BuildContext context) {
return Container(
color: Theme.of(context).cardColor,
child: Column(
children: [
Row(
children: [
Padding(
padding: const EdgeInsets.all(10.0),
child: Card(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.0),
),
color: Color(0xDFFFFFFF),
child: Container(
width: 100,
padding: EdgeInsets.fromLTRB(0, 20, 0, 20),
child: Column(
children: [
Text(
2021-02-01 10:20:39 +00:00
(plan.storage / (1024 * 1024 * 1024))
.round()
.toString() +
" GB",
2021-01-06 16:09:42 +00:00
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
color: Theme.of(context).cardColor,
),
),
],
),
),
),
),
Text(plan.price + " per " + plan.period),
2021-02-06 19:47:54 +00:00
Expanded(child: Container()),
isActive
? Expanded(
child: Icon(
Icons.check_circle,
2021-02-06 19:47:54 +00:00
color: Theme.of(context).accentColor,
),
)
: Container(),
2021-01-06 16:09:42 +00:00
],
),
Divider(
height: 1,
),
],
),
);
}
}
2021-01-10 20:49:22 +00:00
class SubsriptionSuccessfulDialog extends StatelessWidget {
const SubsriptionSuccessfulDialog({Key key}) : super(key: key);
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text("success!",
style: TextStyle(
fontWeight: FontWeight.bold,
)),
content: SingleChildScrollView(
child: Column(children: [
Text("your photos and videos will now be backed up"),
Padding(padding: EdgeInsets.all(6)),
Text("the first sync might take a while, please bear with us"),
]),
),
actions: [
FlatButton(
child: Text("ok"),
onPressed: () {
Navigator.of(context).pop();
},
),
],
);
}
}